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,66 @@
1
+ // Pipeline planner — pure.
2
+ //
3
+ // Given the current source registry + per-source state + the
4
+ // schedule type being run (daily / hourly / weekly), decide
5
+ // which sources should fetch this cycle:
6
+ //
7
+ // 1. Schedule match — a daily run skips hourly sources and
8
+ // vice versa. "on-demand" sources are never picked up by
9
+ // any scheduled run.
10
+ // 2. Backoff respect — sources with a `nextAttemptAt` in the
11
+ // future are skipped until that time arrives, so a flapping
12
+ // source doesn't monopolize the rate limit.
13
+ //
14
+ // Separate module so tests can pin the filtering semantics
15
+ // without touching the rest of the pipeline.
16
+
17
+ import type { Source, SourceSchedule, SourceState } from "../types.js";
18
+
19
+ export interface PlanInput {
20
+ sources: readonly Source[];
21
+ statesBySlug: ReadonlyMap<string, SourceState>;
22
+ // Schedule type this run is handling. The caller (task-manager
23
+ // cron or the manual `rebuild` endpoint) knows which kind it is.
24
+ scheduleType: SourceSchedule;
25
+ // Wall-clock ms since epoch. Passed in (rather than calling
26
+ // Date.now() internally) so tests can drive a deterministic
27
+ // clock.
28
+ nowMs: number;
29
+ }
30
+
31
+ // Sort key: slug, ascending. Deterministic ordering keeps the
32
+ // daily summary's item sequence stable across runs for the same
33
+ // input, which makes markdown diffs readable.
34
+ function bySlug(a: Source, b: Source): number {
35
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
36
+ }
37
+
38
+ // Returns the subset of sources eligible for this cycle. Pure.
39
+ export function planEligibleSources(input: PlanInput): Source[] {
40
+ const eligible: Source[] = [];
41
+ for (const source of input.sources) {
42
+ if (source.schedule !== input.scheduleType) continue;
43
+ if (!isWithinBackoff(input.statesBySlug.get(source.slug), input.nowMs)) {
44
+ eligible.push(source);
45
+ }
46
+ }
47
+ eligible.sort(bySlug);
48
+ return eligible;
49
+ }
50
+
51
+ // True when the state indicates the source is STILL in backoff
52
+ // (so we should SKIP it). False means eligible to run now.
53
+ //
54
+ // - No state at all → run.
55
+ // - No nextAttemptAt → run.
56
+ // - nextAttemptAt unparseable → run (don't let a corrupt state
57
+ // file permanently lock out a source).
58
+ // - nextAttemptAt in the future → skip.
59
+ // - nextAttemptAt at or before now → run.
60
+ function isWithinBackoff(state: SourceState | undefined, nowMs: number): boolean {
61
+ if (!state) return false;
62
+ if (!state.nextAttemptAt) return false;
63
+ const ts = Date.parse(state.nextAttemptAt);
64
+ if (!Number.isFinite(ts)) return false;
65
+ return ts > nowMs;
66
+ }
@@ -0,0 +1,189 @@
1
+ // Daily-summary generator.
2
+ //
3
+ // Takes the cross-source-deduped list of new items and asks
4
+ // `claude` (haiku, budget-capped) to produce the human-readable
5
+ // daily brief markdown. The pipeline then pairs that markdown
6
+ // with a machine-readable JSON block (see write.ts) so the
7
+ // dashboard can consume item metadata without parsing markdown.
8
+ //
9
+ // Shape mirrors `chat-index/summarizer.ts` — same CLI flags, same
10
+ // "errors on STDOUT not stderr" handling, same injectable
11
+ // `SummarizeFn` so tests never invoke the real CLI.
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { tmpdir } from "node:os";
15
+ import { ClaudeCliNotFoundError } from "../../journal/archivist.js";
16
+ import { formatSpawnFailure } from "../../../utils/spawn.js";
17
+ import { errorMessage } from "../../../utils/errors.js";
18
+ import type { SourceItem } from "../types.js";
19
+ import { CLI_SUBPROCESS_TIMEOUT_MS } from "../../../utils/time.js";
20
+
21
+ // A function that takes items and returns markdown. The
22
+ // production implementation spawns claude; tests pass a
23
+ // deterministic stub.
24
+ export type SummarizeFn = (items: readonly SourceItem[]) => Promise<string>;
25
+
26
+ // Wall-clock cap per summarize call. 5 minutes is plenty for a
27
+ // daily brief across a few dozen items; beyond that the call is
28
+ // almost certainly wedged.
29
+ export const DEFAULT_TIMEOUT_MS = CLI_SUBPROCESS_TIMEOUT_MS;
30
+
31
+ // Budget per summarize call. A daily brief is longer and more
32
+ // expensive than a classify call, so the cap is higher than the
33
+ // classifier's $0.05. $0.25 covers several hundred items
34
+ // comfortably.
35
+ const MAX_BUDGET_USD = 0.25;
36
+
37
+ const SYSTEM_PROMPT =
38
+ "You write a daily information brief from a JSON list of items. " +
39
+ "Group items by the `categories` field (one heading per category you see), " +
40
+ "sorted by the most items per category first; within each category, list newest-first by `publishedAt`. " +
41
+ "Use Markdown headings: `# Daily brief — YYYY-MM-DD` as the top heading, then `## <Category>` per group. " +
42
+ "Each item is one bullet: `- [title](url) — one-line summary`. " +
43
+ "Keep summaries under 140 characters. Prefer the item's own summary when present; otherwise paraphrase the title. " +
44
+ "Do NOT emit a JSON block, table of contents, or anything outside the brief itself — the caller appends machine-readable data separately. " +
45
+ "Output Markdown only — no code fences, no prose commentary.";
46
+
47
+ // Shape passed to claude. Kept deliberately compact so the
48
+ // prompt stays within budget even for a busy day: no `content`
49
+ // field (full body), just `summary` truncated to 200 chars.
50
+ interface PromptItem {
51
+ title: string;
52
+ url: string;
53
+ publishedAt: string;
54
+ categories: string[];
55
+ sourceSlug: string;
56
+ summary?: string;
57
+ severity?: string;
58
+ }
59
+
60
+ // Build the user-prompt JSON body. Exported so tests can verify
61
+ // the exact shape the CLI sees, and so a future "generate a
62
+ // test brief without summarizing" workflow can use the same
63
+ // input format.
64
+ export function buildSummarizePromptBody(items: readonly SourceItem[], isoDate: string): string {
65
+ const compactItems: PromptItem[] = items.map((item) => {
66
+ const base: PromptItem = {
67
+ title: item.title,
68
+ url: item.url,
69
+ publishedAt: item.publishedAt,
70
+ categories: [...item.categories],
71
+ sourceSlug: item.sourceSlug,
72
+ };
73
+ if (item.summary) base.summary = item.summary.slice(0, 200);
74
+ if (item.severity) base.severity = item.severity;
75
+ return base;
76
+ });
77
+ return [`DATE: ${isoDate}`, "", "ITEMS (JSON):", JSON.stringify(compactItems, null, 2)].join("\n");
78
+ }
79
+
80
+ // Fallback markdown when there are zero new items today.
81
+ // Writing a file even on an empty day makes it clear the pipeline
82
+ // ran; dashboards can still read it and show "no new items".
83
+ export function buildEmptyDayMarkdown(isoDate: string): string {
84
+ return `# Daily brief — ${isoDate}\n\n_No new items today._\n`;
85
+ }
86
+
87
+ // Pure: parse the CLI envelope, surface structured errors,
88
+ // return the markdown body.
89
+ export function parseSummarizeOutput(stdout: string): string {
90
+ let parsed: {
91
+ is_error?: boolean;
92
+ result?: string;
93
+ errors?: unknown;
94
+ };
95
+ try {
96
+ parsed = JSON.parse(stdout.trim());
97
+ } catch (err) {
98
+ throw new Error(`[sources/summarize] failed to parse claude json: ${errorMessage(err)}`);
99
+ }
100
+ if (parsed.is_error) {
101
+ const msg = Array.isArray(parsed.errors) && parsed.errors.length > 0 ? parsed.errors.join("; ") : (parsed.result ?? "unknown");
102
+ throw new Error(`[sources/summarize] claude error: ${msg}`);
103
+ }
104
+ const result = typeof parsed.result === "string" ? parsed.result : "";
105
+ if (!result) {
106
+ throw new Error("[sources/summarize] claude returned empty / missing result");
107
+ }
108
+ return result;
109
+ }
110
+
111
+ // --- spawn layer --------------------------------------------------------
112
+
113
+ function spawnClaudeSummarize(userPrompt: string, timeoutMs: number): Promise<string> {
114
+ return new Promise((resolve, reject) => {
115
+ // `--output-format json` returns a result envelope containing
116
+ // the model's text response as `.result` — we don't use
117
+ // `--json-schema` here because the model produces free-form
118
+ // markdown. Same "errors on stdout" handling as the
119
+ // classifier / chat-index summarizer.
120
+ const args = [
121
+ "--print",
122
+ "--no-session-persistence",
123
+ "--output-format",
124
+ "json",
125
+ "--model",
126
+ "haiku",
127
+ "--max-budget-usd",
128
+ String(MAX_BUDGET_USD),
129
+ "--system-prompt",
130
+ SYSTEM_PROMPT,
131
+ "-p",
132
+ userPrompt,
133
+ ];
134
+ const proc = spawn("claude", args, {
135
+ cwd: tmpdir(),
136
+ stdio: ["ignore", "pipe", "pipe"],
137
+ });
138
+
139
+ let stdout = "";
140
+ let stderr = "";
141
+ let settled = false;
142
+
143
+ const timer = setTimeout(() => {
144
+ if (settled) return;
145
+ settled = true;
146
+ proc.kill("SIGKILL");
147
+ reject(new Error(`[sources/summarize] claude timed out after ${timeoutMs}ms`));
148
+ }, timeoutMs);
149
+
150
+ proc.stdout.on("data", (chunk: Buffer) => {
151
+ stdout += chunk.toString();
152
+ });
153
+ proc.stderr.on("data", (chunk: Buffer) => {
154
+ stderr += chunk.toString();
155
+ });
156
+ proc.on("error", (err: Error & { code?: string }) => {
157
+ if (settled) return;
158
+ settled = true;
159
+ clearTimeout(timer);
160
+ if (err.code === "ENOENT") {
161
+ reject(new ClaudeCliNotFoundError());
162
+ } else {
163
+ reject(err);
164
+ }
165
+ });
166
+ proc.on("close", (code) => {
167
+ if (settled) return;
168
+ settled = true;
169
+ clearTimeout(timer);
170
+ if (code !== 0) {
171
+ reject(new Error(formatSpawnFailure("[sources/summarize]", code, stdout, stderr)));
172
+ return;
173
+ }
174
+ resolve(stdout);
175
+ });
176
+ });
177
+ }
178
+
179
+ // Build the production SummarizeFn. `isoDate` is captured once
180
+ // per pipeline run so every call in that run uses the same date
181
+ // header (even if the run crosses midnight).
182
+ export function makeDefaultSummarize(isoDate: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): SummarizeFn {
183
+ return async (items) => {
184
+ if (items.length === 0) return buildEmptyDayMarkdown(isoDate);
185
+ const prompt = buildSummarizePromptBody(items, isoDate);
186
+ const stdout = await spawnClaudeSummarize(prompt, timeoutMs);
187
+ return parseSummarizeOutput(stdout);
188
+ };
189
+ }
@@ -0,0 +1,185 @@
1
+ // Write-phase helpers for the source pipeline.
2
+ //
3
+ // Two responsibilities (kept pure where possible, with small
4
+ // async wrappers for the filesystem side):
5
+ //
6
+ // 1. Compose the daily file content — the LLM-written markdown
7
+ // brief + a trailing fenced JSON block the dashboard reads
8
+ // without re-parsing markdown (#188 Q2).
9
+ // 2. Write the daily file + append every item to its
10
+ // source-specific monthly archive under
11
+ // `archive/<slug>/YYYY/MM.md` (#188 Q4).
12
+
13
+ import fsp from "node:fs/promises";
14
+ import path from "node:path";
15
+ import { archivePath, dailyNewsPath } from "../paths.js";
16
+ import { errorMessage } from "../../../utils/errors.js";
17
+ import type { SourceItem } from "../types.js";
18
+ import { writeFileAtomic } from "../../../utils/files/index.js";
19
+
20
+ // --- JSON index --------------------------------------------------------
21
+
22
+ // Compact shape of the JSON block appended at the bottom of each
23
+ // daily file. Mirrors what the dashboard (#143) expects:
24
+ // - itemCount: total items across all categories
25
+ // - byCategory: per-slug counts, sorted-by-count-desc for
26
+ // quick "which genre was hot today" read
27
+ // - items: the raw per-item metadata (no body, no summary) so
28
+ // the dashboard can render compact cards
29
+ export interface DailyJsonIndex {
30
+ itemCount: number;
31
+ byCategory: Record<string, number>;
32
+ items: Array<{
33
+ id: string;
34
+ title: string;
35
+ url: string;
36
+ publishedAt: string;
37
+ categories: string[];
38
+ sourceSlug: string;
39
+ severity?: string;
40
+ }>;
41
+ }
42
+
43
+ export function buildDailyJsonIndex(items: readonly SourceItem[]): DailyJsonIndex {
44
+ const byCategory: Record<string, number> = {};
45
+ for (const item of items) {
46
+ for (const cat of item.categories) {
47
+ byCategory[cat] = (byCategory[cat] ?? 0) + 1;
48
+ }
49
+ }
50
+ return {
51
+ itemCount: items.length,
52
+ byCategory,
53
+ items: items.map((item) => ({
54
+ id: item.id,
55
+ title: item.title,
56
+ url: item.url,
57
+ publishedAt: item.publishedAt,
58
+ categories: [...item.categories],
59
+ sourceSlug: item.sourceSlug,
60
+ ...(item.severity !== undefined && { severity: item.severity }),
61
+ })),
62
+ };
63
+ }
64
+
65
+ // Assemble the full markdown file: claude's brief + a trailing
66
+ // ```json block with the structured index. Pure.
67
+ //
68
+ // `markdown` is the LLM output verbatim (it's supposed to end
69
+ // with a trailing newline already; we guard both cases). The
70
+ // JSON block always ends with a final newline so editors don't
71
+ // complain about "no newline at end of file".
72
+ export function assembleDailyFile(markdown: string, items: readonly SourceItem[]): string {
73
+ const trimmed = markdown.endsWith("\n") ? markdown.slice(0, -1) : markdown;
74
+ const index = buildDailyJsonIndex(items);
75
+ // Pretty-printed JSON — the daily file is meant to be
76
+ // readable in a text editor as much as machine-consumable.
77
+ const json = JSON.stringify(index, null, 2);
78
+ return `${trimmed}\n\n\`\`\`json\n${json}\n\`\`\`\n`;
79
+ }
80
+
81
+ // --- daily file write ---------------------------------------------------
82
+
83
+ // Atomic write: stage to a sibling `.tmp` then rename. Crash
84
+ // mid-write can't leave a half-written daily file visible to
85
+ // downstream readers.
86
+ export async function writeDailyFile(workspaceRoot: string, isoDate: string, markdown: string, items: readonly SourceItem[]): Promise<string> {
87
+ const target = dailyNewsPath(workspaceRoot, isoDate);
88
+ await writeFileAtomic(target, assembleDailyFile(markdown, items));
89
+ return target;
90
+ }
91
+
92
+ // --- per-source archive append -----------------------------------------
93
+
94
+ // Each item lands in its source's monthly archive file under
95
+ // `archive/<slug>/YYYY/MM.md`. Per-month bucket chosen in
96
+ // #188 Q4 — keeps single-year browsing doable without per-day
97
+ // explosion.
98
+
99
+ // Pure: render one item as the markdown block we append to the
100
+ // archive. Exported for tests; idempotent-safe so re-appending
101
+ // the same item produces the same bytes.
102
+ export function renderItemForArchive(item: SourceItem): string {
103
+ const lines: string[] = [];
104
+ lines.push(`## ${item.title}`);
105
+ lines.push("");
106
+ lines.push(`- **Published:** ${item.publishedAt}`);
107
+ lines.push(`- **Source:** ${item.sourceSlug}`);
108
+ lines.push(`- **URL:** ${item.url}`);
109
+ if (item.categories.length > 0) {
110
+ lines.push(`- **Categories:** ${item.categories.join(", ")}`);
111
+ }
112
+ if (item.severity) {
113
+ lines.push(`- **Severity:** ${item.severity}`);
114
+ }
115
+ if (item.summary) {
116
+ lines.push("");
117
+ lines.push(item.summary);
118
+ }
119
+ if (item.content && item.content !== item.summary) {
120
+ lines.push("");
121
+ lines.push(item.content);
122
+ }
123
+ lines.push("");
124
+ lines.push("---");
125
+ lines.push("");
126
+ return lines.join("\n");
127
+ }
128
+
129
+ // ISO `publishedAt` → `YYYY-MM` used as the archive-month key.
130
+ // Malformed dates fall back to the caller-supplied default
131
+ // (typically the current YYYY-MM) so we don't drop items.
132
+ export function archiveMonthFor(isoPublishedAt: string, fallbackMonth: string): string {
133
+ const ts = Date.parse(isoPublishedAt);
134
+ if (!Number.isFinite(ts)) return fallbackMonth;
135
+ const d = new Date(ts);
136
+ const year = d.getUTCFullYear();
137
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
138
+ return `${year}-${String(month).padStart(2, "0")}`;
139
+ }
140
+
141
+ // Group items by (sourceSlug, YYYY-MM) so each destination file
142
+ // gets one append instead of many. Exported so the orchestrator
143
+ // can size the concurrency and tests can pin the bucketing logic.
144
+ export function groupItemsForArchive(items: readonly SourceItem[], fallbackMonth: string): Map<string, SourceItem[]> {
145
+ const groups = new Map<string, SourceItem[]>();
146
+ for (const item of items) {
147
+ const month = archiveMonthFor(item.publishedAt, fallbackMonth);
148
+ const key = `${item.sourceSlug}::${month}`;
149
+ const existing = groups.get(key);
150
+ if (existing) existing.push(item);
151
+ else groups.set(key, [item]);
152
+ }
153
+ return groups;
154
+ }
155
+
156
+ // Append every item's rendered markdown block to the appropriate
157
+ // `archive/<slug>/YYYY/MM.md`. Idempotency is the caller's
158
+ // responsibility (deduping via `stableItemId` earlier in the
159
+ // pipeline) — this helper blindly appends.
160
+ //
161
+ // Errors on individual source groups are collected, not thrown,
162
+ // so one bad group can't lose the others. Returns the list of
163
+ // successfully-written archive paths.
164
+ export async function appendItemsToArchives(
165
+ workspaceRoot: string,
166
+ items: readonly SourceItem[],
167
+ fallbackMonth: string,
168
+ ): Promise<{ writtenPaths: string[]; errors: string[] }> {
169
+ const writtenPaths: string[] = [];
170
+ const errors: string[] = [];
171
+ const groups = groupItemsForArchive(items, fallbackMonth);
172
+ for (const [key, groupItems] of groups) {
173
+ const [slug, month] = key.split("::");
174
+ try {
175
+ const target = archivePath(workspaceRoot, slug, month);
176
+ await fsp.mkdir(path.dirname(target), { recursive: true });
177
+ const body = groupItems.map(renderItemForArchive).join("");
178
+ await fsp.appendFile(target, body, "utf-8");
179
+ writtenPaths.push(target);
180
+ } catch (err) {
181
+ errors.push(`[sources/archive] ${slug}/${month}: ${errorMessage(err)}`);
182
+ }
183
+ }
184
+ return { writtenPaths, errors };
185
+ }
@@ -0,0 +1,148 @@
1
+ // Per-host rate limiter for outbound HTTP fetches.
2
+ //
3
+ // Design decision from #188 Q7: fetchers run parallel across hosts
4
+ // but serial per host. This module is the "serial per host" part.
5
+ // Two concurrent requests to the same hostname wait in FIFO order;
6
+ // different hostnames proceed independently.
7
+ //
8
+ // Mechanism:
9
+ //
10
+ // - Per-host chain: one `Promise<void>` per hostname. Each new
11
+ // request `await`s the previous promise, runs the work, then
12
+ // releases the next waiter.
13
+ // - Minimum delay: optional per-host floor between the end of one
14
+ // request and the start of the next. Defaults to
15
+ // DEFAULT_MIN_DELAY_MS to keep us well-behaved on cooperative
16
+ // hosts; robots.txt `Crawl-delay` callers pass their host's
17
+ // value.
18
+ // - Clock-injectable: the `now` + `sleep` deps make this testable
19
+ // without wall-clock waits.
20
+ //
21
+ // Pure in the sense that all state is captured in the limiter
22
+ // instance — no module-level globals. Tests can spin up a fresh
23
+ // limiter per case and fully observe the ordering.
24
+
25
+ import { ONE_SECOND_MS } from "../../utils/time.js";
26
+
27
+ // Seconds of quiet between requests to the same host when the
28
+ // caller doesn't specify an explicit `minDelayMs` for the host.
29
+ // One second is polite-default for public feeds.
30
+ export const DEFAULT_MIN_DELAY_MS = ONE_SECOND_MS;
31
+
32
+ export interface RateLimiterDeps {
33
+ // Returns current wall-clock milliseconds. `Date.now` in prod,
34
+ // tests inject a controllable counter.
35
+ now: () => number;
36
+ // Sleep for `ms` milliseconds. `setTimeout` wrapper in prod,
37
+ // tests inject a faketimer-backed implementation.
38
+ sleep: (ms: number) => Promise<void>;
39
+ }
40
+
41
+ export function defaultRateLimiterDeps(): RateLimiterDeps {
42
+ return {
43
+ now: () => Date.now(),
44
+ sleep: (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
45
+ };
46
+ }
47
+
48
+ interface HostState {
49
+ // Tail of the per-host promise chain. New work waits for this,
50
+ // then replaces it with its own completion promise.
51
+ tail: Promise<void>;
52
+ // Wall-clock time the most-recent completed request returned,
53
+ // or `null` when no request has completed yet. Using `null`
54
+ // rather than 0 as the sentinel avoids ambiguity against fake
55
+ // test clocks that legitimately start at t=0.
56
+ lastFinishedAt: number | null;
57
+ // Number of tasks currently queued or in-flight for this host.
58
+ // Must hit zero before the host is eligible for eviction —
59
+ // otherwise evictIdle can delete a state with pending work,
60
+ // letting a later run() recreate a fresh chain and break serial
61
+ // ordering.
62
+ activeCount: number;
63
+ }
64
+
65
+ export class HostRateLimiter {
66
+ private readonly hosts = new Map<string, HostState>();
67
+ private readonly deps: RateLimiterDeps;
68
+
69
+ constructor(deps: RateLimiterDeps = defaultRateLimiterDeps()) {
70
+ this.deps = deps;
71
+ }
72
+
73
+ // Run `task` under the host's rate-limit slot. Resolves with the
74
+ // task's return value, or rejects with the task's error (without
75
+ // poisoning the queue — the next waiter still gets to run).
76
+ //
77
+ // `minDelayMs` is the minimum ms between the END of the previous
78
+ // request to this host and the START of this one. Defaults to
79
+ // DEFAULT_MIN_DELAY_MS.
80
+ run<T>(host: string, task: () => Promise<T>, minDelayMs: number = DEFAULT_MIN_DELAY_MS): Promise<T> {
81
+ const key = host.toLowerCase();
82
+ const state: HostState = this.hosts.get(key) ?? {
83
+ tail: Promise.resolve(),
84
+ lastFinishedAt: null,
85
+ activeCount: 0,
86
+ };
87
+ const prev = state.tail;
88
+
89
+ // Build the new tail: wait for prev, enforce delay, run task.
90
+ let resolveTail: () => void = () => {};
91
+ const newTail = new Promise<void>((resolve) => {
92
+ resolveTail = resolve;
93
+ });
94
+ state.tail = newTail;
95
+ state.activeCount++;
96
+ this.hosts.set(key, state);
97
+
98
+ return (async () => {
99
+ try {
100
+ await prev;
101
+ const wait = state.lastFinishedAt === null ? 0 : minDelayMs - (this.deps.now() - state.lastFinishedAt);
102
+ if (wait > 0) await this.deps.sleep(wait);
103
+ try {
104
+ return await task();
105
+ } finally {
106
+ // Mark finished time even on error so a flapping host
107
+ // doesn't get spammed with retries. Read state from the
108
+ // map (rather than the closure-captured variable) in
109
+ // case a parallel call has since updated it.
110
+ const fresh = this.hosts.get(key);
111
+ if (fresh) fresh.lastFinishedAt = this.deps.now();
112
+ }
113
+ } finally {
114
+ resolveTail();
115
+ const fresh = this.hosts.get(key);
116
+ if (fresh) fresh.activeCount--;
117
+ }
118
+ })();
119
+ }
120
+
121
+ // Test / debug: how many hosts currently have a chain. Returns
122
+ // 0 when the limiter is fresh.
123
+ hostCount(): number {
124
+ return this.hosts.size;
125
+ }
126
+
127
+ // Release internal state for hosts that have been idle longer
128
+ // than `idleMs`. Not strictly required for correctness (the
129
+ // map grows linearly with distinct hosts, which is bounded for
130
+ // any real workspace), but handy for long-lived processes.
131
+ evictIdle(idleMs: number): number {
132
+ const cutoff = this.deps.now() - idleMs;
133
+ let removed = 0;
134
+ for (const [key, state] of this.hosts) {
135
+ // Only evict states whose queue is empty. An idle
136
+ // `lastFinishedAt` alone isn't enough — if `tail` still has
137
+ // queued or in-flight work we'd delete live state and a
138
+ // later run() would recreate a fresh chain, breaking serial
139
+ // per-host ordering.
140
+ if (state.activeCount > 0) continue;
141
+ if (state.lastFinishedAt !== null && state.lastFinishedAt < cutoff) {
142
+ this.hosts.delete(key);
143
+ removed++;
144
+ }
145
+ }
146
+ return removed;
147
+ }
148
+ }