mulmoclaude 0.1.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 (408) hide show
  1. package/README.md +44 -0
  2. package/bin/mulmoclaude.js +202 -0
  3. package/bin/prepare-dist.js +93 -0
  4. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +1 -0
  5. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +5 -0
  6. package/client/assets/index-D8rhwXLq.js +4906 -0
  7. package/client/assets/index-KNLBjwuh.css +1 -0
  8. package/client/assets/index.es-D4YyL_Dg-BfRHLTZV.js +5 -0
  9. package/client/assets/material-icons-Dr0goTwe.woff +0 -0
  10. package/client/assets/material-icons-kAwBdRge.woff2 +0 -0
  11. package/client/assets/material-icons-outlined-BpWbwl2n.woff +0 -0
  12. package/client/assets/material-icons-outlined-DZhiGvEA.woff2 +0 -0
  13. package/client/assets/material-icons-round-BDlwx-sv.woff +0 -0
  14. package/client/assets/material-icons-round-DrirKXBx.woff2 +0 -0
  15. package/client/assets/material-icons-sharp-CH1KkVu7.woff +0 -0
  16. package/client/assets/material-icons-sharp-gidztirS.woff2 +0 -0
  17. package/client/assets/material-icons-two-tone-B7wz7mED.woff +0 -0
  18. package/client/assets/material-icons-two-tone-DuNIpaEj.woff2 +0 -0
  19. package/client/assets/mulmo_bw-ERmkSv0a.png +0 -0
  20. package/client/assets/purify.es-Fx1Nqyry-PeS5RUhs.js +2 -0
  21. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +1 -0
  22. package/client/index.html +28 -0
  23. package/package.json +66 -0
  24. package/server/agent/attachmentConverter.ts +270 -0
  25. package/server/agent/config.ts +414 -0
  26. package/server/agent/index.ts +260 -0
  27. package/server/agent/mcp-server.ts +412 -0
  28. package/server/agent/mcp-tools/index.ts +63 -0
  29. package/server/agent/mcp-tools/x.ts +188 -0
  30. package/server/agent/plugin-names.ts +75 -0
  31. package/server/agent/prompt.ts +349 -0
  32. package/server/agent/resumeFailover.ts +129 -0
  33. package/server/agent/sandboxMounts.ts +329 -0
  34. package/server/agent/stream.ts +194 -0
  35. package/server/api/auth/bearerAuth.ts +61 -0
  36. package/server/api/auth/token.ts +98 -0
  37. package/server/api/csrfGuard.ts +85 -0
  38. package/server/api/routes/agent.ts +478 -0
  39. package/server/api/routes/chart.ts +98 -0
  40. package/server/api/routes/chat-index.ts +46 -0
  41. package/server/api/routes/config.ts +258 -0
  42. package/server/api/routes/dispatchResponse.ts +79 -0
  43. package/server/api/routes/files.ts +812 -0
  44. package/server/api/routes/html.ts +101 -0
  45. package/server/api/routes/image.ts +169 -0
  46. package/server/api/routes/mulmo-script.ts +712 -0
  47. package/server/api/routes/mulmoScriptValidate.ts +101 -0
  48. package/server/api/routes/notifications.ts +69 -0
  49. package/server/api/routes/pdf.ts +163 -0
  50. package/server/api/routes/plugins.ts +276 -0
  51. package/server/api/routes/presentHtml.ts +48 -0
  52. package/server/api/routes/roles.ts +125 -0
  53. package/server/api/routes/scheduler.ts +153 -0
  54. package/server/api/routes/schedulerHandlers.ts +151 -0
  55. package/server/api/routes/schedulerTasks.ts +163 -0
  56. package/server/api/routes/sessions.ts +294 -0
  57. package/server/api/routes/sessionsCursor.ts +59 -0
  58. package/server/api/routes/skills.ts +195 -0
  59. package/server/api/routes/sources.ts +540 -0
  60. package/server/api/routes/todos.ts +263 -0
  61. package/server/api/routes/todosColumnsHandlers.ts +347 -0
  62. package/server/api/routes/todosHandlers.ts +274 -0
  63. package/server/api/routes/todosItemsHandlers.ts +386 -0
  64. package/server/api/routes/wiki/pageIndex.ts +53 -0
  65. package/server/api/routes/wiki.ts +363 -0
  66. package/server/api/sandboxStatus.ts +64 -0
  67. package/server/events/notifications.ts +160 -0
  68. package/server/events/pub-sub/index.ts +45 -0
  69. package/server/events/relay-client.ts +288 -0
  70. package/server/events/scheduler-adapter.ts +302 -0
  71. package/server/events/session-store/index.ts +492 -0
  72. package/server/events/task-manager/index.ts +181 -0
  73. package/server/index.ts +572 -0
  74. package/server/system/config.ts +243 -0
  75. package/server/system/credentials.ts +220 -0
  76. package/server/system/docker.ts +97 -0
  77. package/server/system/env.ts +109 -0
  78. package/server/system/logger/config.ts +112 -0
  79. package/server/system/logger/formatters.ts +40 -0
  80. package/server/system/logger/index.ts +53 -0
  81. package/server/system/logger/rotation.ts +37 -0
  82. package/server/system/logger/sinks.ts +101 -0
  83. package/server/system/logger/types.ts +29 -0
  84. package/server/utils/date.ts +57 -0
  85. package/server/utils/errors.ts +7 -0
  86. package/server/utils/fetch.ts +27 -0
  87. package/server/utils/files/atomic.ts +125 -0
  88. package/server/utils/files/html-io.ts +20 -0
  89. package/server/utils/files/image-store.ts +66 -0
  90. package/server/utils/files/index.ts +45 -0
  91. package/server/utils/files/journal-io.ts +213 -0
  92. package/server/utils/files/json.ts +69 -0
  93. package/server/utils/files/markdown-store.ts +33 -0
  94. package/server/utils/files/naming.ts +50 -0
  95. package/server/utils/files/reference-dirs-io.ts +45 -0
  96. package/server/utils/files/roles-io.ts +45 -0
  97. package/server/utils/files/safe.ts +106 -0
  98. package/server/utils/files/scheduler-io.ts +20 -0
  99. package/server/utils/files/scheduler-overrides-io.ts +64 -0
  100. package/server/utils/files/session-io.ts +136 -0
  101. package/server/utils/files/spreadsheet-store.ts +63 -0
  102. package/server/utils/files/todos-io.ts +29 -0
  103. package/server/utils/files/user-tasks-io.ts +25 -0
  104. package/server/utils/files/workspace-io.ts +221 -0
  105. package/server/utils/gemini.ts +59 -0
  106. package/server/utils/gitignore.ts +69 -0
  107. package/server/utils/http.ts +15 -0
  108. package/server/utils/httpError.ts +61 -0
  109. package/server/utils/id.ts +16 -0
  110. package/server/utils/json.ts +83 -0
  111. package/server/utils/logBackgroundError.ts +22 -0
  112. package/server/utils/markdown.ts +82 -0
  113. package/server/utils/request.ts +29 -0
  114. package/server/utils/slug.ts +50 -0
  115. package/server/utils/spawn.ts +62 -0
  116. package/server/utils/time.ts +34 -0
  117. package/server/utils/types.ts +47 -0
  118. package/server/workspace/chat-index/index.ts +153 -0
  119. package/server/workspace/chat-index/indexer.ts +209 -0
  120. package/server/workspace/chat-index/paths.ts +34 -0
  121. package/server/workspace/chat-index/summarizer.ts +247 -0
  122. package/server/workspace/chat-index/types.ts +38 -0
  123. package/server/workspace/custom-dirs.ts +220 -0
  124. package/server/workspace/helps/business.md +104 -0
  125. package/server/workspace/helps/github.md +23 -0
  126. package/server/workspace/helps/index.md +60 -0
  127. package/server/workspace/helps/mulmoscript.md +249 -0
  128. package/server/workspace/helps/sandbox.md +90 -0
  129. package/server/workspace/helps/spreadsheet.md +43 -0
  130. package/server/workspace/helps/telegram.md +135 -0
  131. package/server/workspace/helps/wiki.md +131 -0
  132. package/server/workspace/journal/archivist.ts +386 -0
  133. package/server/workspace/journal/dailyPass.ts +743 -0
  134. package/server/workspace/journal/diff.ts +71 -0
  135. package/server/workspace/journal/index.ts +185 -0
  136. package/server/workspace/journal/indexFile.ts +136 -0
  137. package/server/workspace/journal/linkRewrite.ts +4 -0
  138. package/server/workspace/journal/memoryExtractor.ts +130 -0
  139. package/server/workspace/journal/optimizationPass.ts +160 -0
  140. package/server/workspace/journal/paths.ts +76 -0
  141. package/server/workspace/journal/state.ts +125 -0
  142. package/server/workspace/paths.ts +158 -0
  143. package/server/workspace/reference-dirs.ts +252 -0
  144. package/server/workspace/roles.ts +37 -0
  145. package/server/workspace/skills/discovery.ts +125 -0
  146. package/server/workspace/skills/index.ts +10 -0
  147. package/server/workspace/skills/parser.ts +144 -0
  148. package/server/workspace/skills/paths.ts +41 -0
  149. package/server/workspace/skills/scheduler.ts +149 -0
  150. package/server/workspace/skills/types.ts +30 -0
  151. package/server/workspace/skills/user-tasks.ts +257 -0
  152. package/server/workspace/skills/writer.ts +189 -0
  153. package/server/workspace/sources/arxivDiscovery.ts +182 -0
  154. package/server/workspace/sources/classifier.ts +268 -0
  155. package/server/workspace/sources/fetchers/arxiv.ts +170 -0
  156. package/server/workspace/sources/fetchers/github.ts +106 -0
  157. package/server/workspace/sources/fetchers/githubIssues.ts +208 -0
  158. package/server/workspace/sources/fetchers/githubReleases.ts +186 -0
  159. package/server/workspace/sources/fetchers/index.ts +71 -0
  160. package/server/workspace/sources/fetchers/registerAll.ts +15 -0
  161. package/server/workspace/sources/fetchers/rss.ts +141 -0
  162. package/server/workspace/sources/fetchers/rssParser.ts +295 -0
  163. package/server/workspace/sources/httpFetcher.ts +230 -0
  164. package/server/workspace/sources/interests.ts +120 -0
  165. package/server/workspace/sources/paths.ts +110 -0
  166. package/server/workspace/sources/pipeline/dedup.ts +60 -0
  167. package/server/workspace/sources/pipeline/fetch.ts +136 -0
  168. package/server/workspace/sources/pipeline/index.ts +249 -0
  169. package/server/workspace/sources/pipeline/notify.ts +72 -0
  170. package/server/workspace/sources/pipeline/plan.ts +66 -0
  171. package/server/workspace/sources/pipeline/summarize.ts +189 -0
  172. package/server/workspace/sources/pipeline/write.ts +185 -0
  173. package/server/workspace/sources/rateLimiter.ts +148 -0
  174. package/server/workspace/sources/registry.ts +326 -0
  175. package/server/workspace/sources/robots.ts +271 -0
  176. package/server/workspace/sources/sourceState.ts +135 -0
  177. package/server/workspace/sources/taxonomy.ts +74 -0
  178. package/server/workspace/sources/types.ts +144 -0
  179. package/server/workspace/sources/urls.ts +112 -0
  180. package/server/workspace/tool-trace/classify.ts +114 -0
  181. package/server/workspace/tool-trace/index.ts +250 -0
  182. package/server/workspace/tool-trace/writeSearch.ts +98 -0
  183. package/server/workspace/wiki-backlinks/index.ts +107 -0
  184. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +144 -0
  185. package/server/workspace/workspace.ts +66 -0
  186. package/src/App.vue +720 -0
  187. package/src/assets/mulmo_bw.png +0 -0
  188. package/src/components/CanvasViewToggle.vue +27 -0
  189. package/src/components/ChatAttachmentPreview.vue +45 -0
  190. package/src/components/ChatImagePreview.vue +17 -0
  191. package/src/components/ChatInput.vue +208 -0
  192. package/src/components/FileContentHeader.vue +49 -0
  193. package/src/components/FileContentRenderer.vue +162 -0
  194. package/src/components/FileTree.vue +115 -0
  195. package/src/components/FileTreePane.vue +85 -0
  196. package/src/components/FilesView.vue +206 -0
  197. package/src/components/LockStatusPopup.vue +111 -0
  198. package/src/components/NotificationBell.vue +131 -0
  199. package/src/components/NotificationToast.vue +72 -0
  200. package/src/components/PluginLauncher.vue +138 -0
  201. package/src/components/RightSidebar.vue +113 -0
  202. package/src/components/RoleSelector.vue +64 -0
  203. package/src/components/SessionHistoryPanel.vue +176 -0
  204. package/src/components/SessionTabBar.vue +81 -0
  205. package/src/components/SettingsMcpTab.vue +350 -0
  206. package/src/components/SettingsModal.vue +275 -0
  207. package/src/components/SettingsReferenceDirsTab.vue +173 -0
  208. package/src/components/SettingsWorkspaceDirsTab.vue +174 -0
  209. package/src/components/SidebarHeader.vue +69 -0
  210. package/src/components/StackView.vue +360 -0
  211. package/src/components/SuggestionsPanel.vue +65 -0
  212. package/src/components/TodoExplorer.vue +358 -0
  213. package/src/components/ToolResultsPanel.vue +77 -0
  214. package/src/components/todo/TodoAddDialog.vue +131 -0
  215. package/src/components/todo/TodoEditDialog.vue +47 -0
  216. package/src/components/todo/TodoEditPanel.vue +113 -0
  217. package/src/components/todo/TodoKanbanView.vue +249 -0
  218. package/src/components/todo/TodoListView.vue +79 -0
  219. package/src/components/todo/TodoTableView.vue +177 -0
  220. package/src/composables/useActiveSession.ts +40 -0
  221. package/src/composables/useAppApi.ts +45 -0
  222. package/src/composables/useCanvasViewMode.ts +121 -0
  223. package/src/composables/useChatScroll.ts +47 -0
  224. package/src/composables/useClickOutside.ts +26 -0
  225. package/src/composables/useClipboardCopy.ts +44 -0
  226. package/src/composables/useContentDisplay.ts +52 -0
  227. package/src/composables/useDebugBeat.ts +23 -0
  228. package/src/composables/useDynamicFavicon.ts +115 -0
  229. package/src/composables/useEventListeners.ts +42 -0
  230. package/src/composables/useExpandedDirs.ts +64 -0
  231. package/src/composables/useFaviconState.ts +30 -0
  232. package/src/composables/useFileSelection.ts +115 -0
  233. package/src/composables/useFileSortMode.ts +24 -0
  234. package/src/composables/useFileTree.ts +85 -0
  235. package/src/composables/useFreshPluginData.ts +89 -0
  236. package/src/composables/useHealth.ts +38 -0
  237. package/src/composables/useImeAwareEnter.ts +57 -0
  238. package/src/composables/useKeyNavigation.ts +60 -0
  239. package/src/composables/useMarkdownLinkHandler.ts +46 -0
  240. package/src/composables/useMarkdownMode.ts +17 -0
  241. package/src/composables/useMcpTools.ts +71 -0
  242. package/src/composables/useMergedSessions.ts +27 -0
  243. package/src/composables/useNotifications.ts +90 -0
  244. package/src/composables/usePdfDownload.ts +60 -0
  245. package/src/composables/usePendingCalls.ts +77 -0
  246. package/src/composables/usePubSub.ts +85 -0
  247. package/src/composables/useRightSidebar.ts +23 -0
  248. package/src/composables/useRoles.ts +34 -0
  249. package/src/composables/useSandboxStatus.ts +67 -0
  250. package/src/composables/useSelectedResult.ts +49 -0
  251. package/src/composables/useSessionDerived.ts +51 -0
  252. package/src/composables/useSessionHistory.ts +81 -0
  253. package/src/composables/useSessionSync.ts +57 -0
  254. package/src/composables/useViewLayout.ts +55 -0
  255. package/src/config/apiRoutes.ts +173 -0
  256. package/src/config/pubsubChannels.ts +45 -0
  257. package/src/config/roles.ts +335 -0
  258. package/src/config/schedulerActions.ts +25 -0
  259. package/src/config/toolNames.ts +71 -0
  260. package/src/config/workspacePaths.ts +24 -0
  261. package/src/index.css +107 -0
  262. package/src/main.ts +25 -0
  263. package/src/plugins/canvas/Preview.vue +13 -0
  264. package/src/plugins/canvas/View.vue +333 -0
  265. package/src/plugins/canvas/definition.ts +38 -0
  266. package/src/plugins/canvas/index.ts +36 -0
  267. package/src/plugins/chart/Preview.vue +49 -0
  268. package/src/plugins/chart/View.vue +143 -0
  269. package/src/plugins/chart/definition.ts +58 -0
  270. package/src/plugins/chart/index.ts +52 -0
  271. package/src/plugins/editImage/Preview.vue +13 -0
  272. package/src/plugins/editImage/View.vue +13 -0
  273. package/src/plugins/editImage/definition.ts +27 -0
  274. package/src/plugins/editImage/index.ts +36 -0
  275. package/src/plugins/generateImage/Preview.vue +13 -0
  276. package/src/plugins/generateImage/View.vue +33 -0
  277. package/src/plugins/generateImage/definition.ts +32 -0
  278. package/src/plugins/generateImage/index.ts +56 -0
  279. package/src/plugins/manageRoles/Preview.vue +49 -0
  280. package/src/plugins/manageRoles/View.vue +525 -0
  281. package/src/plugins/manageRoles/definition.ts +43 -0
  282. package/src/plugins/manageRoles/index.ts +47 -0
  283. package/src/plugins/manageSkills/Preview.vue +21 -0
  284. package/src/plugins/manageSkills/View.vue +321 -0
  285. package/src/plugins/manageSkills/definition.ts +49 -0
  286. package/src/plugins/manageSkills/index.ts +49 -0
  287. package/src/plugins/manageSource/Preview.vue +33 -0
  288. package/src/plugins/manageSource/View.vue +697 -0
  289. package/src/plugins/manageSource/definition.ts +63 -0
  290. package/src/plugins/manageSource/index.ts +66 -0
  291. package/src/plugins/markdown/Preview.vue +77 -0
  292. package/src/plugins/markdown/View.vue +476 -0
  293. package/src/plugins/markdown/definition.ts +50 -0
  294. package/src/plugins/markdown/index.ts +36 -0
  295. package/src/plugins/presentHtml/Preview.vue +25 -0
  296. package/src/plugins/presentHtml/View.vue +52 -0
  297. package/src/plugins/presentHtml/definition.ts +27 -0
  298. package/src/plugins/presentHtml/helpers.ts +72 -0
  299. package/src/plugins/presentHtml/index.ts +41 -0
  300. package/src/plugins/presentMulmoScript/Preview.vue +23 -0
  301. package/src/plugins/presentMulmoScript/View.vue +1166 -0
  302. package/src/plugins/presentMulmoScript/definition.ts +95 -0
  303. package/src/plugins/presentMulmoScript/helpers.ts +162 -0
  304. package/src/plugins/presentMulmoScript/index.ts +40 -0
  305. package/src/plugins/scheduler/Preview.vue +67 -0
  306. package/src/plugins/scheduler/TasksTab.vue +205 -0
  307. package/src/plugins/scheduler/View.vue +565 -0
  308. package/src/plugins/scheduler/definition.ts +57 -0
  309. package/src/plugins/scheduler/index.ts +45 -0
  310. package/src/plugins/scheduler/viewModes.ts +26 -0
  311. package/src/plugins/spreadsheet/Preview.vue +29 -0
  312. package/src/plugins/spreadsheet/View.vue +997 -0
  313. package/src/plugins/spreadsheet/cellHighlights.ts +79 -0
  314. package/src/plugins/spreadsheet/definition.ts +121 -0
  315. package/src/plugins/spreadsheet/engine/calculator.ts +459 -0
  316. package/src/plugins/spreadsheet/engine/cellBuilder.ts +81 -0
  317. package/src/plugins/spreadsheet/engine/date-parser.ts +220 -0
  318. package/src/plugins/spreadsheet/engine/date-utils.ts +56 -0
  319. package/src/plugins/spreadsheet/engine/engine.ts +176 -0
  320. package/src/plugins/spreadsheet/engine/evaluator.ts +390 -0
  321. package/src/plugins/spreadsheet/engine/formatter.ts +172 -0
  322. package/src/plugins/spreadsheet/engine/formulaRefs.ts +101 -0
  323. package/src/plugins/spreadsheet/engine/functions/date.ts +299 -0
  324. package/src/plugins/spreadsheet/engine/functions/financial.ts +387 -0
  325. package/src/plugins/spreadsheet/engine/functions/index.ts +16 -0
  326. package/src/plugins/spreadsheet/engine/functions/logical.ts +262 -0
  327. package/src/plugins/spreadsheet/engine/functions/lookup.ts +400 -0
  328. package/src/plugins/spreadsheet/engine/functions/mathematical.ts +297 -0
  329. package/src/plugins/spreadsheet/engine/functions/statistical.ts +338 -0
  330. package/src/plugins/spreadsheet/engine/functions/text.ts +389 -0
  331. package/src/plugins/spreadsheet/engine/index.ts +27 -0
  332. package/src/plugins/spreadsheet/engine/jsonCellLocator.ts +111 -0
  333. package/src/plugins/spreadsheet/engine/parser.ts +143 -0
  334. package/src/plugins/spreadsheet/engine/registry.ts +150 -0
  335. package/src/plugins/spreadsheet/engine/responseDecoder.ts +67 -0
  336. package/src/plugins/spreadsheet/engine/types.ts +64 -0
  337. package/src/plugins/spreadsheet/index.ts +36 -0
  338. package/src/plugins/textResponse/Preview.vue +94 -0
  339. package/src/plugins/textResponse/View.vue +503 -0
  340. package/src/plugins/textResponse/definition.ts +34 -0
  341. package/src/plugins/textResponse/index.ts +27 -0
  342. package/src/plugins/textResponse/plugin.ts +29 -0
  343. package/src/plugins/textResponse/samples.ts +97 -0
  344. package/src/plugins/textResponse/types.ts +11 -0
  345. package/src/plugins/todo/Preview.vue +63 -0
  346. package/src/plugins/todo/View.vue +364 -0
  347. package/src/plugins/todo/composables/useTodos.ts +177 -0
  348. package/src/plugins/todo/definition.ts +45 -0
  349. package/src/plugins/todo/index.ts +61 -0
  350. package/src/plugins/todo/labels.ts +163 -0
  351. package/src/plugins/todo/priority.ts +98 -0
  352. package/src/plugins/todo/viewModes.ts +19 -0
  353. package/src/plugins/ui-image/ImagePreview.vue +23 -0
  354. package/src/plugins/ui-image/ImageView.vue +34 -0
  355. package/src/plugins/ui-image/index.ts +3 -0
  356. package/src/plugins/ui-image/types.ts +4 -0
  357. package/src/plugins/wiki/Preview.vue +65 -0
  358. package/src/plugins/wiki/View.vue +342 -0
  359. package/src/plugins/wiki/definition.ts +25 -0
  360. package/src/plugins/wiki/helpers.ts +59 -0
  361. package/src/plugins/wiki/index.ts +52 -0
  362. package/src/router/guards.ts +61 -0
  363. package/src/router/index.ts +50 -0
  364. package/src/tools/index.ts +52 -0
  365. package/src/tools/types.ts +27 -0
  366. package/src/types/events.ts +16 -0
  367. package/src/types/fileTree.ts +13 -0
  368. package/src/types/notification.ts +67 -0
  369. package/src/types/session.ts +116 -0
  370. package/src/types/sse.ts +90 -0
  371. package/src/types/toolCallHistory.ts +13 -0
  372. package/src/utils/agent/eventDispatch.ts +74 -0
  373. package/src/utils/agent/request.ts +55 -0
  374. package/src/utils/agent/toolCalls.ts +62 -0
  375. package/src/utils/api.ts +218 -0
  376. package/src/utils/canvas/viewMode.ts +46 -0
  377. package/src/utils/dom/authTokenMeta.ts +20 -0
  378. package/src/utils/dom/clickOutside.ts +11 -0
  379. package/src/utils/dom/externalLink.ts +57 -0
  380. package/src/utils/dom/scrollable.ts +24 -0
  381. package/src/utils/errors.ts +11 -0
  382. package/src/utils/files/expandedDirs.ts +25 -0
  383. package/src/utils/files/filename.ts +12 -0
  384. package/src/utils/files/sortChildren.ts +20 -0
  385. package/src/utils/filesPreview/schedulerPreview.ts +38 -0
  386. package/src/utils/filesPreview/todoPreview.ts +40 -0
  387. package/src/utils/format/date.ts +85 -0
  388. package/src/utils/format/frontmatter.ts +80 -0
  389. package/src/utils/format/jsonSyntax.ts +109 -0
  390. package/src/utils/html/previewCsp.ts +65 -0
  391. package/src/utils/image/resolve.ts +8 -0
  392. package/src/utils/image/rewriteMarkdownImageRefs.ts +182 -0
  393. package/src/utils/markdown/extractFirstH1.ts +39 -0
  394. package/src/utils/notification/dispatch.ts +22 -0
  395. package/src/utils/path/relativeLink.ts +130 -0
  396. package/src/utils/role/icon.ts +20 -0
  397. package/src/utils/role/merge.ts +10 -0
  398. package/src/utils/role/plugins.ts +12 -0
  399. package/src/utils/session/mergeSessions.ts +103 -0
  400. package/src/utils/session/seedRoleDefault.ts +35 -0
  401. package/src/utils/session/sessionEntries.ts +121 -0
  402. package/src/utils/session/sessionFactory.ts +22 -0
  403. package/src/utils/session/sessionHelpers.ts +99 -0
  404. package/src/utils/tools/dedup.ts +17 -0
  405. package/src/utils/tools/mcp.ts +33 -0
  406. package/src/utils/tools/pendingCalls.ts +16 -0
  407. package/src/utils/tools/result.ts +40 -0
  408. package/src/utils/types.ts +44 -0
@@ -0,0 +1,208 @@
1
+ // GitHub Issues + PRs fetcher.
2
+ //
3
+ // Source config shape (PRs included by default — GitHub's REST
4
+ // `/issues` endpoint returns issues AND pulls, and the common
5
+ // use case for UC-5 in the plan is tracking both):
6
+ //
7
+ // fetcher_kind: github-issues
8
+ // github_repo: receptron/mulmoclaude
9
+ // github_issue_state: open # optional: open | closed | all
10
+ // github_include_prs: true # optional: true | false
11
+ //
12
+ // Flow: GET /repos/:owner/:repo/issues?state=...&since=...&sort=updated
13
+ // → JSON array (issues + pulls) → parse each → optionally filter
14
+ // out PRs → filter by cursor (updated_at > cursor) → normalize.
15
+ //
16
+ // Cursor strategy: we pass `since=<lastSeen>` as a server-side
17
+ // pre-filter AND also filter locally, because `since` is
18
+ // "updated at OR later" (inclusive) while we want strictly after.
19
+ // Cursor key: `github_issues_last_updated_at`.
20
+
21
+ import { normalizeUrl, stableItemId } from "../urls.js";
22
+ import type { Source, SourceItem, SourceState } from "../types.js";
23
+ import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
24
+ import { registerFetcher } from "./index.js";
25
+ import { GITHUB_API_BASE, GithubFetcherError, githubFetchJson, isRecord, parseRepoSlug } from "./github.js";
26
+ import { firstParagraph } from "./githubReleases.js";
27
+
28
+ export const ISSUES_CURSOR_KEY = "github_issues_last_updated_at";
29
+
30
+ // Whitelist of values the GitHub API accepts for `state`. A typo
31
+ // here (e.g. `state=Open` uppercase) returns 422 so we validate.
32
+ const ISSUE_STATES = new Set(["open", "closed", "all"]);
33
+
34
+ interface IssuesParams {
35
+ state: "open" | "closed" | "all";
36
+ includePrs: boolean;
37
+ }
38
+
39
+ // Parse + default the optional fetcherParams. Returns the
40
+ // resolved params. Invalid values fall back to defaults rather
41
+ // than erroring — a typo in the source file shouldn't silently
42
+ // break the daily pipeline.
43
+ export function resolveIssuesParams(params: Record<string, string>): IssuesParams {
44
+ const rawState = params["github_issue_state"];
45
+ const state = typeof rawState === "string" && ISSUE_STATES.has(rawState) ? (rawState as "open" | "closed" | "all") : "open";
46
+ const rawInclude = params["github_include_prs"];
47
+ // Any string value other than the literal "false" counts as
48
+ // true. Users don't usually explicitly set it; if they do,
49
+ // they probably want `false`.
50
+ const includePrs = rawInclude !== "false";
51
+ return { state, includePrs };
52
+ }
53
+
54
+ interface ParsedIssue {
55
+ id: number | null;
56
+ number: number | null;
57
+ title: string | null;
58
+ htmlUrl: string | null;
59
+ body: string | null;
60
+ updatedAt: string | null;
61
+ createdAt: string | null;
62
+ isPr: boolean;
63
+ state: string | null;
64
+ }
65
+
66
+ // Narrow one GitHub issue record into ParsedIssue. Pure —
67
+ // exported for unit tests. `pull_request` field being present
68
+ // (even if empty) is GitHub's canonical "this issue is a PR"
69
+ // signal.
70
+ export function parseGithubIssue(raw: unknown): ParsedIssue | null {
71
+ if (!isRecord(raw)) return null;
72
+ const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
73
+ const issueNumber = typeof raw.number === "number" && Number.isFinite(raw.number) ? raw.number : null;
74
+ const title = typeof raw.title === "string" ? raw.title : null;
75
+ const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
76
+ const body = typeof raw.body === "string" ? raw.body : null;
77
+ const updatedAt = typeof raw.updated_at === "string" ? raw.updated_at : null;
78
+ const createdAt = typeof raw.created_at === "string" ? raw.created_at : null;
79
+ const state = typeof raw.state === "string" ? raw.state : null;
80
+ // `pull_request` present in ANY form (object with url, empty
81
+ // object) means this is a PR. Absence means it's an issue.
82
+ const isPr = "pull_request" in raw && raw.pull_request !== undefined && raw.pull_request !== null;
83
+ return {
84
+ id,
85
+ number: issueNumber,
86
+ title,
87
+ htmlUrl,
88
+ body,
89
+ updatedAt,
90
+ createdAt,
91
+ isPr,
92
+ state,
93
+ };
94
+ }
95
+
96
+ // Build a SourceItem from a parsed issue + the parent Source.
97
+ // Returns null when the item should be skipped (missing URL,
98
+ // cursor-old, PR when PRs excluded).
99
+ export function issueToSourceItem(issue: ParsedIssue, source: Source, params: IssuesParams, lastSeenTs: number | null): SourceItem | null {
100
+ if (issue.isPr && !params.includePrs) return null;
101
+ if (!issue.htmlUrl || !issue.updatedAt) return null;
102
+
103
+ const updatedTs = Date.parse(issue.updatedAt);
104
+ if (Number.isFinite(updatedTs) && lastSeenTs !== null) {
105
+ // `since` is inclusive — re-filter strictly greater locally
106
+ // so an item updated at the exact cursor time doesn't emit
107
+ // again next run.
108
+ if (updatedTs <= lastSeenTs) return null;
109
+ }
110
+
111
+ const normalizedUrl = normalizeUrl(issue.htmlUrl);
112
+ if (!normalizedUrl) return null;
113
+ const id = stableItemId(normalizedUrl);
114
+
115
+ // Title annotations: `[PR]` for pulls, `[closed]` for closed
116
+ // state so the daily summary makes state visible at a glance.
117
+ const parts: string[] = [];
118
+ if (issue.isPr) parts.push("[PR]");
119
+ if (issue.state === "closed") parts.push("[closed]");
120
+ const baseTitle = issue.title ?? `#${issue.number ?? "?"}`;
121
+ const title = parts.length > 0 ? `${parts.join(" ")} ${baseTitle}` : baseTitle;
122
+
123
+ const summary = issue.body ? firstParagraph(issue.body) : null;
124
+
125
+ return {
126
+ id,
127
+ title,
128
+ url: normalizedUrl,
129
+ publishedAt: new Date(updatedTs).toISOString(),
130
+ ...(summary !== null && { summary }),
131
+ ...(issue.body !== null && { content: issue.body }),
132
+ categories: source.categories,
133
+ sourceSlug: source.slug,
134
+ };
135
+ }
136
+
137
+ export function updateIssuesCursor(current: Record<string, string>, issues: readonly ParsedIssue[], params: IssuesParams): Record<string, string> {
138
+ let newest: number | null = null;
139
+ for (const issue of issues) {
140
+ if (issue.isPr && !params.includePrs) continue;
141
+ if (!issue.updatedAt) continue;
142
+ const ts = Date.parse(issue.updatedAt);
143
+ if (!Number.isFinite(ts)) continue;
144
+ if (newest === null || ts > newest) newest = ts;
145
+ }
146
+ if (newest === null) return current;
147
+ const currentTs = current[ISSUES_CURSOR_KEY] ? Date.parse(current[ISSUES_CURSOR_KEY]) : -Infinity;
148
+ if (newest <= currentTs) return current;
149
+ return {
150
+ ...current,
151
+ [ISSUES_CURSOR_KEY]: new Date(newest).toISOString(),
152
+ };
153
+ }
154
+
155
+ // Pure: run parse + filter + cursor-advance on an already-fetched
156
+ // body, so tests can exercise the normalizer path without HTTP.
157
+ export function processIssuesResponse(rawBody: unknown, source: Source, params: IssuesParams, cursor: Record<string, string>): FetchResult {
158
+ if (!Array.isArray(rawBody)) return { items: [], cursor };
159
+ const parsed: ParsedIssue[] = [];
160
+ for (const raw of rawBody) {
161
+ const issue = parseGithubIssue(raw);
162
+ if (issue) parsed.push(issue);
163
+ }
164
+ const lastSeenTs = cursor[ISSUES_CURSOR_KEY] ? Date.parse(cursor[ISSUES_CURSOR_KEY]) : null;
165
+ const effectiveLastSeen = lastSeenTs !== null && Number.isFinite(lastSeenTs) ? lastSeenTs : null;
166
+
167
+ const items: SourceItem[] = [];
168
+ for (const issue of parsed) {
169
+ if (items.length >= source.maxItemsPerFetch) break;
170
+ const item = issueToSourceItem(issue, source, params, effectiveLastSeen);
171
+ if (item) items.push(item);
172
+ }
173
+ return { items, cursor: updateIssuesCursor(cursor, parsed, params) };
174
+ }
175
+
176
+ // Build the GitHub issues URL. `since` and `per_page` are set
177
+ // for freshness + a reasonable upper bound (the API caps at 100).
178
+ // `sort=updated&direction=desc` pairs with the cursor so newest
179
+ // items arrive first.
180
+ export function issuesUrl(owner: string, repo: string, state: string, since: string | null, perPage: number): string {
181
+ const params = new URLSearchParams();
182
+ params.set("state", state);
183
+ params.set("sort", "updated");
184
+ params.set("direction", "desc");
185
+ // GitHub API accepts max 100 per page. Clamp defensively.
186
+ const clamped = Math.max(1, Math.min(100, Math.floor(perPage)));
187
+ params.set("per_page", String(clamped));
188
+ if (since) params.set("since", since);
189
+ return `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?${params.toString()}`;
190
+ }
191
+
192
+ export const githubIssuesFetcher: SourceFetcher = {
193
+ kind: "github-issues",
194
+ async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
195
+ const repoRaw = source.fetcherParams["github_repo"];
196
+ const slug = parseRepoSlug(repoRaw ?? "");
197
+ if (!slug) {
198
+ throw new GithubFetcherError(source.url, 0, `github_repo param is required and must be owner/repo, got ${JSON.stringify(repoRaw)}`);
199
+ }
200
+ const params = resolveIssuesParams(source.fetcherParams);
201
+ const since = state.cursor[ISSUES_CURSOR_KEY] ?? null;
202
+ const url = issuesUrl(slug.owner, slug.repo, params.state, since, source.maxItemsPerFetch);
203
+ const body = await githubFetchJson(url, deps.http);
204
+ return processIssuesResponse(body, source, params, state.cursor);
205
+ },
206
+ };
207
+
208
+ registerFetcher(githubIssuesFetcher);
@@ -0,0 +1,186 @@
1
+ // GitHub Releases fetcher.
2
+ //
3
+ // Source config shape:
4
+ //
5
+ // fetcher_kind: github-releases
6
+ // github_repo: anthropics/claude-code
7
+ //
8
+ // Flow: GET /repos/:owner/:repo/releases → JSON array → parse each
9
+ // release → filter against cursor (published_at) → normalize to
10
+ // SourceItem.
11
+ //
12
+ // Cursor key: `github_releases_last_published_at` — ISO timestamp
13
+ // of the newest release we've emitted. Separate key from the RSS
14
+ // cursor so a source transitioning between fetcher kinds doesn't
15
+ // mishandle state.
16
+ //
17
+ // Unauthenticated only in phase 1. The 60 req/hour/IP rate-limit
18
+ // is plenty for a workspace with a handful of repos.
19
+
20
+ import { normalizeUrl, stableItemId } from "../urls.js";
21
+ import type { Source, SourceItem, SourceState } from "../types.js";
22
+ import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
23
+ import { registerFetcher } from "./index.js";
24
+ import { GITHUB_API_BASE, GithubFetcherError, githubFetchJson, isRecord, parseRepoSlug } from "./github.js";
25
+
26
+ export const RELEASES_CURSOR_KEY = "github_releases_last_published_at";
27
+
28
+ // GitHub Releases endpoint. 30 releases per page by default; we
29
+ // cap at `source.maxItemsPerFetch` downstream. Phase-1 doesn't
30
+ // paginate — one page covers every active project's last ~1-2
31
+ // years of releases, plenty for the cursor to advance on.
32
+ function releasesUrl(owner: string, repo: string): string {
33
+ return `${GITHUB_API_BASE}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases`;
34
+ }
35
+
36
+ // One parsed release. Mirrors the GitHub API shape we actually
37
+ // read (we drop the other ~20 fields for clarity and to keep the
38
+ // normalizer testable in isolation).
39
+ interface ParsedRelease {
40
+ id: number | null;
41
+ name: string | null;
42
+ tagName: string | null;
43
+ htmlUrl: string | null;
44
+ body: string | null;
45
+ publishedAt: string | null;
46
+ draft: boolean;
47
+ prerelease: boolean;
48
+ }
49
+
50
+ // Narrow one GitHub release record into ParsedRelease. Pure —
51
+ // exported so tests can exercise the JSON-shape handling without
52
+ // hitting the network.
53
+ export function parseGithubRelease(raw: unknown): ParsedRelease | null {
54
+ if (!isRecord(raw)) return null;
55
+ const id = typeof raw.id === "number" && Number.isFinite(raw.id) ? raw.id : null;
56
+ const name = typeof raw.name === "string" ? raw.name : null;
57
+ const tagName = typeof raw.tag_name === "string" ? raw.tag_name : null;
58
+ const htmlUrl = typeof raw.html_url === "string" ? raw.html_url : null;
59
+ const body = typeof raw.body === "string" ? raw.body : null;
60
+ const publishedAt = typeof raw.published_at === "string" ? raw.published_at : null;
61
+ const draft = raw.draft === true;
62
+ const prerelease = raw.prerelease === true;
63
+ return { id, name, tagName, htmlUrl, body, publishedAt, draft, prerelease };
64
+ }
65
+
66
+ // Build a SourceItem from a parsed release + the parent Source.
67
+ // Returns null when the release doesn't carry the fields we need
68
+ // to make a useful item (missing URL, or cursor says we've seen
69
+ // this release already).
70
+ export function releaseToSourceItem(release: ParsedRelease, source: Source, lastSeenTs: number | null): SourceItem | null {
71
+ // Drafts are private — GitHub only shows them to authed readers.
72
+ // But defensively skip if the API somehow returns one.
73
+ if (release.draft) return null;
74
+ if (!release.htmlUrl || !release.publishedAt) return null;
75
+
76
+ // Cursor filter: drop releases at-or-older than the cursor.
77
+ // Null cursor means first run → pass through.
78
+ const publishedTs = Date.parse(release.publishedAt);
79
+ if (Number.isFinite(publishedTs) && lastSeenTs !== null) {
80
+ if (publishedTs <= lastSeenTs) return null;
81
+ }
82
+
83
+ const normalizedUrl = normalizeUrl(release.htmlUrl);
84
+ if (!normalizedUrl) return null;
85
+ const id = stableItemId(normalizedUrl);
86
+
87
+ // Title resolution: prefer <name> (release display name), fall
88
+ // back to <tag_name> (e.g. "v1.2.3"). Annotate pre-releases so
89
+ // the daily summary can visually distinguish them.
90
+ const baseTitle = release.name ?? release.tagName ?? "Release";
91
+ const title = release.prerelease ? `[pre] ${baseTitle}` : baseTitle;
92
+ const summary = release.body ? firstParagraph(release.body) : null;
93
+
94
+ return {
95
+ id,
96
+ title,
97
+ url: normalizedUrl,
98
+ publishedAt: new Date(publishedTs).toISOString(),
99
+ ...(summary !== null && { summary }),
100
+ ...(release.body !== null && { content: release.body }),
101
+ categories: source.categories,
102
+ sourceSlug: source.slug,
103
+ };
104
+ }
105
+
106
+ // Extract the first "paragraph" of a release body for the short
107
+ // summary. GitHub release bodies are Markdown — we take everything
108
+ // up to the first double-newline so multi-line markdown lists or
109
+ // images in paragraph 2+ don't bloat the summary.
110
+ //
111
+ // Pure; exported for tests.
112
+ export function firstParagraph(body: string): string | null {
113
+ const trimmed = body.trim();
114
+ if (!trimmed) return null;
115
+ const doubleNewline = trimmed.indexOf("\n\n");
116
+ const head = doubleNewline === -1 ? trimmed : trimmed.slice(0, doubleNewline);
117
+ return head.length > 0 ? head : null;
118
+ }
119
+
120
+ // After filtering, advance the cursor to the newest publishedAt
121
+ // across ALL parsed releases in the response — not just the
122
+ // emitted ones — so a quiet repo doesn't keep replaying its last
123
+ // release on every run.
124
+ //
125
+ // Exported pure for direct unit testing.
126
+ export function updateReleasesCursor(current: Record<string, string>, releases: readonly ParsedRelease[]): Record<string, string> {
127
+ let newest: number | null = null;
128
+ for (const release of releases) {
129
+ if (release.draft) continue;
130
+ if (!release.publishedAt) continue;
131
+ const ts = Date.parse(release.publishedAt);
132
+ if (!Number.isFinite(ts)) continue;
133
+ if (newest === null || ts > newest) newest = ts;
134
+ }
135
+ if (newest === null) return current;
136
+ const currentTs = current[RELEASES_CURSOR_KEY] ? Date.parse(current[RELEASES_CURSOR_KEY]) : -Infinity;
137
+ if (newest <= currentTs) return current;
138
+ return {
139
+ ...current,
140
+ [RELEASES_CURSOR_KEY]: new Date(newest).toISOString(),
141
+ };
142
+ }
143
+
144
+ // Pure: run the parse + filter + cursor-advance pipeline on an
145
+ // already-fetched JSON body. Exposed separately from the fetch
146
+ // itself so we can test the full normalization path with
147
+ // fabricated API responses and no HTTP stubbing.
148
+ export function processReleasesResponse(rawBody: unknown, source: Source, cursor: Record<string, string>): FetchResult {
149
+ if (!Array.isArray(rawBody)) {
150
+ return { items: [], cursor };
151
+ }
152
+ const parsed: ParsedRelease[] = [];
153
+ for (const raw of rawBody) {
154
+ const release = parseGithubRelease(raw);
155
+ if (release) parsed.push(release);
156
+ }
157
+ const lastSeenTs = cursor[RELEASES_CURSOR_KEY] ? Date.parse(cursor[RELEASES_CURSOR_KEY]) : null;
158
+ const effectiveLastSeen = lastSeenTs !== null && Number.isFinite(lastSeenTs) ? lastSeenTs : null;
159
+
160
+ const items: SourceItem[] = [];
161
+ for (const release of parsed) {
162
+ if (items.length >= source.maxItemsPerFetch) break;
163
+ const item = releaseToSourceItem(release, source, effectiveLastSeen);
164
+ if (item) items.push(item);
165
+ }
166
+ return {
167
+ items,
168
+ cursor: updateReleasesCursor(cursor, parsed),
169
+ };
170
+ }
171
+
172
+ export const githubReleasesFetcher: SourceFetcher = {
173
+ kind: "github-releases",
174
+ async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
175
+ const repoRaw = source.fetcherParams["github_repo"];
176
+ const slug = parseRepoSlug(repoRaw ?? "");
177
+ if (!slug) {
178
+ throw new GithubFetcherError(source.url, 0, `github_repo param is required and must be owner/repo, got ${JSON.stringify(repoRaw)}`);
179
+ }
180
+ const url = releasesUrl(slug.owner, slug.repo);
181
+ const body = await githubFetchJson(url, deps.http);
182
+ return processReleasesResponse(body, source, state.cursor);
183
+ },
184
+ };
185
+
186
+ registerFetcher(githubReleasesFetcher);
@@ -0,0 +1,71 @@
1
+ // Fetcher dispatcher.
2
+ //
3
+ // Each source in the registry has a `fetcherKind` that maps to a
4
+ // module under `server/sources/fetchers/<kind>.ts` implementing
5
+ // the `SourceFetcher` interface. The pipeline looks up the right
6
+ // fetcher via `getFetcher(kind)`, then calls `fetcher.fetch(...)`.
7
+ //
8
+ // Adding a new fetcher kind in phase 2 / 3 / later:
9
+ // 1. Create `server/sources/fetchers/<new-kind>.ts` exporting
10
+ // a `SourceFetcher` and calling `registerFetcher(...)` at
11
+ // the bottom so the module self-registers on import.
12
+ // 2. Add the string to `FETCHER_KINDS` in `../types.ts`.
13
+ // 3. **Add a side-effect import for the new module to
14
+ // `./registerAll.ts`.** Production entry points import that
15
+ // barrel; without this step the pipeline still resolves
16
+ // `getFetcher(kind)` to `null` and your fetcher never runs.
17
+ // 4. Add a case to `test/sources/test_fetcherRegistration.ts`
18
+ // so regressions fail a unit test.
19
+ // No other framework change is required.
20
+
21
+ import type { HttpFetcherDeps } from "../httpFetcher.js";
22
+ import type { FetcherKind, Source, SourceItem, SourceState } from "../types.js";
23
+
24
+ // Per-run dependencies threaded into every fetcher so all I/O
25
+ // goes through injectable hooks (tests never touch the network).
26
+ export interface FetcherDeps {
27
+ // Wiring for `fetchPolite` — robots provider, rate limiter,
28
+ // fetch impl, timeout. Shared across fetchers in one pipeline
29
+ // run so their per-host rate limits serialize together.
30
+ http: HttpFetcherDeps;
31
+ // Monotonic wall-clock. Fetchers timestamp new items and update
32
+ // the cursor with it.
33
+ now: () => number;
34
+ // Pipeline-wide abort (e.g. server shutdown). Separate from
35
+ // the per-fetch timeout that lives inside `http`.
36
+ signal?: AbortSignal;
37
+ }
38
+
39
+ export interface FetchResult {
40
+ // Newly-discovered items (already filtered against the cursor).
41
+ // Empty array is a valid outcome — "no new items since last run".
42
+ items: SourceItem[];
43
+ // Replacement cursor for SourceState. Fetchers return only the
44
+ // keys they own; the caller merges this into the existing state.
45
+ cursor: Record<string, string>;
46
+ }
47
+
48
+ export interface SourceFetcher {
49
+ readonly kind: FetcherKind;
50
+ fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult>;
51
+ }
52
+
53
+ // Registry of all known fetchers. Populated lazily via
54
+ // `registerFetcher` so each fetcher module is responsible for
55
+ // adding itself at import time — keeps the dispatcher free of
56
+ // hard dependencies on modules that may pull heavy deps
57
+ // (fast-xml-parser etc).
58
+ const FETCHERS = new Map<FetcherKind, SourceFetcher>();
59
+
60
+ export function registerFetcher(fetcher: SourceFetcher): void {
61
+ FETCHERS.set(fetcher.kind, fetcher);
62
+ }
63
+
64
+ export function getFetcher(kind: FetcherKind): SourceFetcher | null {
65
+ return FETCHERS.get(kind) ?? null;
66
+ }
67
+
68
+ // Test-only: clear the registry between cases.
69
+ export function __resetFetchersForTests(): void {
70
+ FETCHERS.clear();
71
+ }
@@ -0,0 +1,15 @@
1
+ // Side-effect bootstrap: importing this module registers every
2
+ // known fetcher with the dispatcher in `./index.ts`. Each fetcher
3
+ // module calls `registerFetcher(...)` at import time.
4
+ //
5
+ // The dispatcher itself intentionally does not import the fetcher
6
+ // modules (see the comment in `./index.ts`) so it stays free of
7
+ // heavy parser dependencies and tests can register only the
8
+ // fetchers they need. Production entry points that run the
9
+ // pipeline must import this barrel once so `getFetcher(kind)`
10
+ // returns a non-null result for every FetcherKind.
11
+
12
+ import "./rss.js";
13
+ import "./githubReleases.js";
14
+ import "./githubIssues.js";
15
+ import "./arxiv.js";
@@ -0,0 +1,141 @@
1
+ // RSS / Atom source fetcher.
2
+ //
3
+ // Flow:
4
+ // 1. fetchPolite(source.url) — respects robots, User-Agent,
5
+ // rate limit, timeout
6
+ // 2. parseFeed(bodyText) — pure XML → ParsedFeed
7
+ // 3. normalizeToSourceItems(...) — ParsedFeedItem[] → SourceItem[]
8
+ // with cursor filtering so we only emit items newer than the
9
+ // last-seen pubDate
10
+ // 4. updateCursor(...) — advance the cursor to the most-recent
11
+ // publishedAt across ALL items in this response (not just
12
+ // the emitted ones) so a quiet feed doesn't keep replaying
13
+ // old items after a one-shot republish
14
+ //
15
+ // The parser / normalizer / cursor logic are pure functions
16
+ // exported for direct unit tests. The `rssFetcher` object is the
17
+ // Promise-aware orchestrator that stitches them together.
18
+
19
+ import { fetchPolite } from "../httpFetcher.js";
20
+ import { normalizeUrl, stableItemId } from "../urls.js";
21
+ import type { Source, SourceItem, SourceState } from "../types.js";
22
+ import type { FetcherDeps, FetchResult, SourceFetcher } from "./index.js";
23
+ import { registerFetcher } from "./index.js";
24
+ import { parseFeed, type ParsedFeed, type ParsedFeedItem } from "./rssParser.js";
25
+
26
+ // Cursor key we store in SourceState.cursor for RSS feeds.
27
+ // ISO timestamp of the most-recent item's publishedAt we've seen.
28
+ // Items whose publishedAt is <= this value are skipped on the
29
+ // next run. Separate key name from other fetchers so adding
30
+ // GitHub / arXiv cursors next to RSS ones on the same source
31
+ // never conflicts.
32
+ export const RSS_CURSOR_KEY = "rss_last_seen_at";
33
+
34
+ export class RssFetcherError extends Error {
35
+ readonly url: string;
36
+ readonly status: number | null;
37
+ constructor(url: string, status: number | null, message: string) {
38
+ super(message);
39
+ this.name = "RssFetcherError";
40
+ this.url = url;
41
+ this.status = status;
42
+ }
43
+ }
44
+
45
+ // Filter raw parsed items against the cursor and normalize into
46
+ // the pipeline's `SourceItem` shape. Pure — exported so tests
47
+ // can exercise cursor semantics with fabricated ParsedFeed
48
+ // structures without spinning up HTTP.
49
+ export function normalizeToSourceItems(feed: ParsedFeed, source: Source, cursor: Record<string, string>, maxItems: number): SourceItem[] {
50
+ const lastSeen = cursor[RSS_CURSOR_KEY] ?? null;
51
+ const lastSeenTs = lastSeen ? Date.parse(lastSeen) : null;
52
+ const items: SourceItem[] = [];
53
+
54
+ for (const entry of feed.items) {
55
+ if (items.length >= maxItems) break;
56
+ const item = entryToSourceItem(entry, source, lastSeenTs);
57
+ if (item) items.push(item);
58
+ }
59
+ return items;
60
+ }
61
+
62
+ function entryToSourceItem(entry: ParsedFeedItem, source: Source, lastSeenTs: number | null): SourceItem | null {
63
+ if (!entry.link) return null;
64
+ const normalizedUrl = normalizeUrl(entry.link);
65
+ if (!normalizedUrl) return null;
66
+ // Cursor comparison: drop items at or older than the last-seen
67
+ // timestamp. Null publishedAt → keep (rare but happens with
68
+ // misformatted feeds; we'd rather emit a dup once than lose
69
+ // an item forever).
70
+ if (entry.publishedAt && lastSeenTs !== null) {
71
+ const itemTs = Date.parse(entry.publishedAt);
72
+ if (Number.isFinite(itemTs) && itemTs <= lastSeenTs) return null;
73
+ }
74
+ // Use the feed's own id as a hint, but always derive the
75
+ // SourceItem.id from the normalized URL so cross-source dedup
76
+ // (see #188 Q3) lines up regardless of feed conventions.
77
+ const id = stableItemId(normalizedUrl);
78
+ const publishedAt =
79
+ entry.publishedAt ??
80
+ // Synthesize a fetch-time timestamp when the feed didn't
81
+ // provide one, so downstream sorting has a monotonic key.
82
+ new Date().toISOString();
83
+
84
+ // Build the SourceItem with conditional spreads so we don't
85
+ // carry `undefined` fields that break exactOptionalPropertyTypes
86
+ // on the server tsconfig.
87
+ return {
88
+ id,
89
+ title: entry.title,
90
+ url: normalizedUrl,
91
+ publishedAt,
92
+ ...(entry.summary !== null && { summary: entry.summary }),
93
+ ...(entry.content !== null && { content: entry.content }),
94
+ categories: source.categories,
95
+ sourceSlug: source.slug,
96
+ };
97
+ }
98
+
99
+ // After filtering, advance the cursor to the newest publishedAt
100
+ // in the full ParsedFeed (not just the emitted items). Doing
101
+ // this on the full set prevents a feed that republishes older
102
+ // items without updating pubDate from causing us to re-emit
103
+ // them forever.
104
+ //
105
+ // Exported pure so tests can pin the advancement policy down.
106
+ export function updateCursor(current: Record<string, string>, feed: ParsedFeed): Record<string, string> {
107
+ let newest: number | null = null;
108
+ for (const entry of feed.items) {
109
+ if (!entry.publishedAt) continue;
110
+ const ts = Date.parse(entry.publishedAt);
111
+ if (!Number.isFinite(ts)) continue;
112
+ if (newest === null || ts > newest) newest = ts;
113
+ }
114
+ if (newest === null) return current;
115
+ // Only advance forwards. A feed whose newest item is older
116
+ // than our cursor should leave the cursor where it was so
117
+ // we don't retroactively re-emit everything on the next run.
118
+ const currentTs = current[RSS_CURSOR_KEY] ? Date.parse(current[RSS_CURSOR_KEY]) : -Infinity;
119
+ if (newest <= currentTs) return current;
120
+ return { ...current, [RSS_CURSOR_KEY]: new Date(newest).toISOString() };
121
+ }
122
+
123
+ export const rssFetcher: SourceFetcher = {
124
+ kind: "rss",
125
+ async fetch(source: Source, state: SourceState, deps: FetcherDeps): Promise<FetchResult> {
126
+ const res = await fetchPolite(source.url, deps.http);
127
+ if (!res.ok) {
128
+ throw new RssFetcherError(source.url, res.status, `RSS fetch ${source.url} failed with HTTP ${res.status}`);
129
+ }
130
+ const body = await res.text();
131
+ const feed = parseFeed(body);
132
+ if (!feed) {
133
+ throw new RssFetcherError(source.url, res.status, `RSS body at ${source.url} did not parse as RSS / Atom / RDF`);
134
+ }
135
+ const items = normalizeToSourceItems(feed, source, state.cursor, source.maxItemsPerFetch);
136
+ const cursor = updateCursor(state.cursor, feed);
137
+ return { items, cursor };
138
+ },
139
+ };
140
+
141
+ registerFetcher(rssFetcher);