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,257 @@
1
+ // User-created scheduled tasks (#357 Phase 3).
2
+ //
3
+ // Users can create tasks via the API or MCP tool. Each task fires
4
+ // `startChat()` with its prompt when the schedule triggers.
5
+ //
6
+ // Tasks are persisted in `config/scheduler/tasks.json` and
7
+ // registered with the task-manager at startup. CRUD operations
8
+ // trigger a refresh that unregisters old tasks and registers new ones.
9
+
10
+ import { loadUserTasks as loadRaw, saveUserTasks } from "../../utils/files/user-tasks-io.js";
11
+ import type { MissedRunPolicy } from "@receptron/task-scheduler";
12
+ import { SCHEDULE_TYPES, MISSED_RUN_POLICIES } from "@receptron/task-scheduler";
13
+ import type { TaskSchedule as LocalTaskSchedule } from "../../events/task-manager/index.js";
14
+ import { DEFAULT_ROLE_ID } from "../../../src/config/roles.js";
15
+ import { SESSION_ORIGINS, type SessionOrigin } from "../../../src/types/session.js";
16
+ import { log } from "../../system/logger/index.js";
17
+ import type { ITaskManager } from "../../events/task-manager/index.js";
18
+ import { isRecord } from "../../utils/types.js";
19
+
20
+ // ── Types ───────────────────────────────────────────────────────
21
+
22
+ export interface PersistedUserTask {
23
+ id: string;
24
+ name: string;
25
+ description: string;
26
+ schedule: LocalTaskSchedule;
27
+ missedRunPolicy: MissedRunPolicy;
28
+ enabled: boolean;
29
+ roleId: string;
30
+ prompt: string;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ }
34
+
35
+ export function loadUserTasks(r?: string): PersistedUserTask[] {
36
+ return loadRaw<PersistedUserTask>(r);
37
+ }
38
+
39
+ // ── Validation ──────────────────────────────────────────────────
40
+
41
+ function isValidDailyTime(value: string): boolean {
42
+ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value);
43
+ }
44
+
45
+ function isValidSchedule(s: unknown): s is LocalTaskSchedule {
46
+ if (!isRecord(s)) return false;
47
+ const obj = s as Record<string, unknown>;
48
+ if (obj.type === SCHEDULE_TYPES.interval) {
49
+ return typeof obj.intervalMs === "number" && obj.intervalMs > 0;
50
+ }
51
+ if (obj.type === SCHEDULE_TYPES.daily) {
52
+ return typeof obj.time === "string" && isValidDailyTime(obj.time);
53
+ }
54
+ return false;
55
+ }
56
+
57
+ function isValidMissedRunPolicy(p: unknown): p is MissedRunPolicy {
58
+ return p === MISSED_RUN_POLICIES.skip || p === MISSED_RUN_POLICIES.runOnce || p === MISSED_RUN_POLICIES.runAll;
59
+ }
60
+
61
+ export type ValidateResult = { kind: "ok"; task: PersistedUserTask } | { kind: "error"; error: string };
62
+
63
+ export function validateAndCreate(input: unknown): ValidateResult {
64
+ if (!isRecord(input)) {
65
+ return { kind: "error", error: "request body required" };
66
+ }
67
+ const obj = input as Record<string, unknown>;
68
+
69
+ if (typeof obj.name !== "string" || obj.name.trim().length === 0) {
70
+ return { kind: "error", error: "name required" };
71
+ }
72
+ if (typeof obj.prompt !== "string" || obj.prompt.trim().length === 0) {
73
+ return { kind: "error", error: "prompt required" };
74
+ }
75
+ if (!isValidSchedule(obj.schedule)) {
76
+ return { kind: "error", error: "valid schedule required" };
77
+ }
78
+ const missedRunPolicy = isValidMissedRunPolicy(obj.missedRunPolicy) ? obj.missedRunPolicy : MISSED_RUN_POLICIES.runOnce;
79
+ const roleId = typeof obj.roleId === "string" ? obj.roleId : DEFAULT_ROLE_ID;
80
+
81
+ const now = new Date().toISOString();
82
+ const task: PersistedUserTask = {
83
+ id: crypto.randomUUID(),
84
+ name: obj.name.trim(),
85
+ description: typeof obj.description === "string" ? obj.description.trim() : "",
86
+ schedule: obj.schedule,
87
+ missedRunPolicy,
88
+ enabled: true,
89
+ roleId,
90
+ prompt: obj.prompt.trim(),
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ };
94
+ return { kind: "ok", task };
95
+ }
96
+
97
+ export type UpdateResult = { kind: "ok"; tasks: PersistedUserTask[] } | { kind: "error"; error: string };
98
+
99
+ export function applyUpdate(tasks: PersistedUserTask[], id: string, patch: unknown): UpdateResult {
100
+ if (!isRecord(patch)) {
101
+ return { kind: "error", error: "request body required" };
102
+ }
103
+ const idx = tasks.findIndex((t) => t.id === id);
104
+ if (idx === -1) {
105
+ return { kind: "error", error: `task not found: ${id}` };
106
+ }
107
+ const existing = tasks[idx];
108
+ const updated: PersistedUserTask = { ...existing };
109
+ // patch is validated as non-null object above; spread into Record
110
+ const p: Record<string, unknown> = { ...patch };
111
+
112
+ if (typeof p.name === "string" && p.name.trim().length > 0) {
113
+ updated.name = p.name.trim();
114
+ }
115
+ if (typeof p.description === "string") {
116
+ updated.description = p.description.trim();
117
+ }
118
+ if (isValidSchedule(p.schedule)) {
119
+ updated.schedule = p.schedule;
120
+ }
121
+ if (isValidMissedRunPolicy(p.missedRunPolicy)) {
122
+ updated.missedRunPolicy = p.missedRunPolicy;
123
+ }
124
+ if (typeof p.enabled === "boolean") {
125
+ updated.enabled = p.enabled;
126
+ }
127
+ if (typeof p.roleId === "string") {
128
+ updated.roleId = p.roleId;
129
+ }
130
+ if (typeof p.prompt === "string" && p.prompt.trim().length > 0) {
131
+ updated.prompt = p.prompt.trim();
132
+ }
133
+ updated.updatedAt = new Date().toISOString();
134
+
135
+ const next = [...tasks];
136
+ next[idx] = updated;
137
+ return { kind: "ok", tasks: next };
138
+ }
139
+
140
+ // ── Mutexed CRUD ────────────────────────────────────────────────
141
+ // Serialize read-modify-write sequences so concurrent API calls
142
+ // don't clobber each other's changes.
143
+
144
+ let crudMutex: Promise<void> = Promise.resolve();
145
+
146
+ export async function withUserTaskLock<T>(
147
+ fn: (tasks: PersistedUserTask[]) => Promise<{
148
+ tasks: PersistedUserTask[];
149
+ result: T;
150
+ }>,
151
+ ): Promise<T> {
152
+ const prev = crudMutex;
153
+ let release: () => void = () => {};
154
+ crudMutex = new Promise<void>((resolve) => {
155
+ release = resolve;
156
+ });
157
+ try {
158
+ await prev;
159
+ const current = loadUserTasks();
160
+ const { tasks: next, result } = await fn(current);
161
+ await saveUserTasks(next);
162
+ await refreshUserTasks();
163
+ return result;
164
+ } finally {
165
+ release();
166
+ }
167
+ }
168
+
169
+ // ── Task registration ───────────────────────────────────────────
170
+
171
+ const USER_TASK_PREFIX = "user.";
172
+ let registeredUserTaskIds = new Set<string>();
173
+ let cachedUserTaskDeps: UserTaskDeps | null = null;
174
+ let userTaskMutex: Promise<number> = Promise.resolve(0);
175
+
176
+ export interface UserTaskDeps {
177
+ taskManager: ITaskManager;
178
+ startChat: (params: { message: string; roleId: string; chatSessionId: string; origin?: SessionOrigin }) => Promise<{ kind: string; error?: string }>;
179
+ }
180
+
181
+ export async function registerUserTasks(deps: UserTaskDeps): Promise<number> {
182
+ cachedUserTaskDeps = deps;
183
+ return serializedRefreshUserTasks(deps);
184
+ }
185
+
186
+ export async function refreshUserTasks(): Promise<number> {
187
+ if (!cachedUserTaskDeps) {
188
+ log.warn("user-tasks", "refreshUserTasks called before initial register");
189
+ return 0;
190
+ }
191
+ return serializedRefreshUserTasks(cachedUserTaskDeps);
192
+ }
193
+
194
+ function serializedRefreshUserTasks(deps: UserTaskDeps): Promise<number> {
195
+ userTaskMutex = userTaskMutex.then(
196
+ () => doRegisterUserTasks(deps),
197
+ () => doRegisterUserTasks(deps),
198
+ );
199
+ return userTaskMutex;
200
+ }
201
+
202
+ async function doRegisterUserTasks(deps: UserTaskDeps): Promise<number> {
203
+ const { taskManager, startChat } = deps;
204
+
205
+ for (const taskId of registeredUserTaskIds) {
206
+ taskManager.removeTask(taskId);
207
+ }
208
+ const previousCount = registeredUserTaskIds.size;
209
+ registeredUserTaskIds = new Set<string>();
210
+
211
+ const tasks = loadUserTasks();
212
+ let registered = 0;
213
+
214
+ for (const task of tasks) {
215
+ if (!task.enabled) continue;
216
+
217
+ const taskId = `${USER_TASK_PREFIX}${task.id}`;
218
+ taskManager.registerTask({
219
+ id: taskId,
220
+ description: `User task: ${task.name}`,
221
+ schedule: task.schedule,
222
+ run: async () => {
223
+ const chatSessionId = crypto.randomUUID();
224
+ log.info("user-tasks", "running user task", {
225
+ name: task.name,
226
+ roleId: task.roleId,
227
+ chatSessionId,
228
+ });
229
+ const result = await startChat({
230
+ message: task.prompt,
231
+ roleId: task.roleId,
232
+ chatSessionId,
233
+ origin: SESSION_ORIGINS.scheduler,
234
+ });
235
+ if (result.kind === "error") {
236
+ throw new Error(`user task failed: ${result.error ?? "unknown"}`);
237
+ }
238
+ log.info("user-tasks", "user task completed", {
239
+ name: task.name,
240
+ kind: result.kind,
241
+ });
242
+ },
243
+ });
244
+
245
+ registeredUserTaskIds.add(taskId);
246
+ registered++;
247
+ }
248
+
249
+ if (previousCount > 0 || registered > 0) {
250
+ log.info("user-tasks", "user tasks refreshed", {
251
+ previous: previousCount,
252
+ current: registered,
253
+ });
254
+ }
255
+
256
+ return registered;
257
+ }
@@ -0,0 +1,189 @@
1
+ // Project-scope skill writer. Phase 1 of #139.
2
+ //
3
+ // Writes are confined to <workspaceRoot>/.claude/skills/<slug>/SKILL.md.
4
+ // User-scope skills (~/.claude/skills/) are never touched — the
5
+ // safety boundary is enforced by always going through
6
+ // `projectSkillPath` and never accepting an arbitrary destination.
7
+ //
8
+ // `saveProjectSkill` is non-overwriting: if the slug already has a
9
+ // SKILL.md (in either scope), the call returns a `kind: "exists"`
10
+ // result and the file is left alone. The caller (REST handler /
11
+ // MCP bridge) maps this to a 409 Conflict so Claude can ask the
12
+ // user for a different name.
13
+
14
+ import { unlink, rmdir } from "node:fs/promises";
15
+ import { discoverSkills } from "./discovery.js";
16
+ import { projectSkillDir, projectSkillPath } from "./paths.js";
17
+ import { isValidSlug } from "../../utils/slug.js";
18
+ import { log } from "../../system/logger/index.js";
19
+ import { writeFileAtomic } from "../../utils/files/index.js";
20
+
21
+ export interface SaveSkillInput {
22
+ /** Workspace root (typically `~/mulmoclaude`). */
23
+ workspaceRoot: string;
24
+ /** Slug — also the dir name and the slash-command name. */
25
+ name: string;
26
+ /** YAML frontmatter `description:` value. One-line summary. */
27
+ description: string;
28
+ /** Markdown body following the frontmatter. May be empty. */
29
+ body: string;
30
+ }
31
+
32
+ export type SaveResult =
33
+ | { kind: "saved"; path: string }
34
+ | { kind: "invalid-slug"; slug: string }
35
+ | { kind: "missing-field"; field: "description" | "body" }
36
+ | { kind: "exists"; name: string };
37
+
38
+ /**
39
+ * Write a new SKILL.md atomically. Refuses to overwrite — if the
40
+ * skill already exists at either scope, returns `kind: "exists"`.
41
+ */
42
+ export async function saveProjectSkill(input: SaveSkillInput): Promise<SaveResult> {
43
+ const { workspaceRoot, name, description, body } = input;
44
+
45
+ if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
46
+ if (typeof description !== "string" || description.trim().length === 0) {
47
+ return { kind: "missing-field", field: "description" };
48
+ }
49
+ if (typeof body !== "string") {
50
+ return { kind: "missing-field", field: "body" };
51
+ }
52
+
53
+ // Conflict check across BOTH scopes — we don't want to shadow a
54
+ // user-scope skill with the same name (project would silently
55
+ // override it via the precedence rule).
56
+ const existing = await discoverSkills({ workspaceRoot });
57
+ if (existing.some((s) => s.name === name)) {
58
+ return { kind: "exists", name };
59
+ }
60
+
61
+ const finalPath = projectSkillPath(workspaceRoot, name);
62
+ const contents = formatSkillFile(description, body);
63
+
64
+ // Atomic + uniqueTmp: same-FS rename is atomic on POSIX so a
65
+ // partial write can never leave a half-baked SKILL.md visible to a
66
+ // concurrent reader. The uniqueTmp flag guards against leftover
67
+ // `.tmp` from a previous crashed run colliding with a new write.
68
+ try {
69
+ await writeFileAtomic(finalPath, contents, { uniqueTmp: true });
70
+ } catch (err) {
71
+ log.error("skills", "save failed", { name, error: String(err) });
72
+ throw err;
73
+ }
74
+
75
+ return { kind: "saved", path: finalPath };
76
+ }
77
+
78
+ export type UpdateResult =
79
+ | { kind: "updated"; path: string }
80
+ | { kind: "invalid-slug"; slug: string }
81
+ | { kind: "missing-field"; field: "description" | "body" }
82
+ | { kind: "not-found"; name: string }
83
+ | { kind: "user-scope"; name: string };
84
+
85
+ /**
86
+ * Overwrite an existing project-scope SKILL.md. Refuses to touch
87
+ * user-scope skills and rejects names that don't exist.
88
+ */
89
+ export async function updateProjectSkill(input: SaveSkillInput): Promise<UpdateResult> {
90
+ const { workspaceRoot, name, description, body } = input;
91
+
92
+ if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
93
+ if (typeof description !== "string" || description.trim().length === 0) {
94
+ return { kind: "missing-field", field: "description" };
95
+ }
96
+ if (typeof body !== "string") {
97
+ return { kind: "missing-field", field: "body" };
98
+ }
99
+
100
+ const existing = await discoverSkills({ workspaceRoot });
101
+ const skill = existing.find((s) => s.name === name);
102
+ if (!skill) return { kind: "not-found", name };
103
+ if (skill.source === "user") return { kind: "user-scope", name };
104
+
105
+ const finalPath = projectSkillPath(workspaceRoot, name);
106
+ const contents = formatSkillFile(description, body);
107
+
108
+ try {
109
+ await writeFileAtomic(finalPath, contents, { uniqueTmp: true });
110
+ } catch (err) {
111
+ log.error("skills", "update failed", { name, error: String(err) });
112
+ throw err;
113
+ }
114
+
115
+ return { kind: "updated", path: finalPath };
116
+ }
117
+
118
+ export interface DeleteSkillInput {
119
+ workspaceRoot: string;
120
+ name: string;
121
+ }
122
+
123
+ export type DeleteResult =
124
+ | { kind: "deleted"; name: string }
125
+ | { kind: "invalid-slug"; slug: string }
126
+ | { kind: "not-found"; name: string }
127
+ | { kind: "user-scope"; name: string };
128
+
129
+ /**
130
+ * Remove a project-scope skill (SKILL.md + its containing folder).
131
+ * Refuses to touch the user scope even if a user skill with this
132
+ * name exists — protects against accidental ~/.claude mutation.
133
+ */
134
+ export async function deleteProjectSkill(input: DeleteSkillInput): Promise<DeleteResult> {
135
+ const { workspaceRoot, name } = input;
136
+
137
+ if (!isValidSlug(name)) return { kind: "invalid-slug", slug: name };
138
+
139
+ // Look up the skill's effective source via discovery — if the
140
+ // matching name is user-scope, we refuse.
141
+ const all = await discoverSkills({ workspaceRoot });
142
+ const skill = all.find((s) => s.name === name);
143
+ if (!skill) return { kind: "not-found", name };
144
+ if (skill.source === "user") return { kind: "user-scope", name };
145
+
146
+ const dir = projectSkillDir(workspaceRoot, name);
147
+ // Remove SKILL.md, then try to remove the directory if it's empty.
148
+ // If the user has dropped extra files alongside SKILL.md (e.g. a
149
+ // README, assets), rmdir() fails and we leave the directory in
150
+ // place — the skill itself (the SKILL.md) is gone either way.
151
+ try {
152
+ await unlink(projectSkillPath(workspaceRoot, name));
153
+ } catch (err) {
154
+ // ENOENT is fine — discovery may be stale. Anything else is
155
+ // surfaced so the caller knows the delete didn't fully work.
156
+ const error = err as { code?: string };
157
+ if (error.code !== "ENOENT") throw err;
158
+ }
159
+ await rmdir(dir).catch(() => {
160
+ // Dir may contain user-added files (e.g. the user dropped a
161
+ // README.md alongside SKILL.md). Don't fail in that case —
162
+ // the skill itself is gone.
163
+ });
164
+
165
+ return { kind: "deleted", name };
166
+ }
167
+
168
+ /** Compose the final SKILL.md content. Body is trimmed of trailing
169
+ * whitespace; a final newline is always added. */
170
+ function formatSkillFile(description: string, body: string): string {
171
+ const escaped = escapeYamlScalar(description);
172
+ return `---\ndescription: ${escaped}\n---\n\n${body.trimEnd()}\n`;
173
+ }
174
+
175
+ /**
176
+ * Escape a one-line string for use as a YAML scalar value. We
177
+ * stay defensive: if the value contains any character that could
178
+ * confuse the parser (`:`, `#`, `'`, `"`, leading whitespace), wrap
179
+ * it in double quotes and JSON-escape the inner content. Plain
180
+ * ASCII text passes through unchanged so the file stays readable.
181
+ */
182
+ function escapeYamlScalar(value: string): string {
183
+ const oneLine = value.replace(/\r?\n/g, " ").trim();
184
+ const needsQuoting = /[:#'"\\[\]{}>|`*&!%@?]/.test(oneLine) || /^\s|\s$/.test(oneLine) || /^(true|false|null|~|yes|no|on|off)$/i.test(oneLine);
185
+ if (!needsQuoting) return oneLine;
186
+ // JSON.stringify gives us escapes for `\`, `"`, control chars in
187
+ // one shot — the result is also valid YAML when wrapped in `"..."`.
188
+ return JSON.stringify(oneLine);
189
+ }
@@ -0,0 +1,182 @@
1
+ // arXiv auto-discovery (#469).
2
+ //
3
+ // Reads user interests from config/interests.json and automatically
4
+ // registers arXiv sources for keywords that don't already have one.
5
+ // Called from the pipeline's startup or on interest profile change.
6
+ //
7
+ // For each keyword (or group of related keywords), generates an
8
+ // arXiv query and registers it as a daily source. Existing arXiv
9
+ // sources are not duplicated.
10
+
11
+ import crypto from "crypto";
12
+ import { loadInterests } from "./interests.js";
13
+ import { listSources, writeSource } from "./registry.js";
14
+ import { sourcesRoot } from "./paths.js";
15
+ import { workspacePath } from "../paths.js";
16
+ import { log } from "../../system/logger/index.js";
17
+ import { slugify } from "../../utils/slug.js";
18
+ import type { Source } from "./types.js";
19
+ import fs from "fs";
20
+
21
+ // ── Constants ───────────────────────────────────────────────────
22
+
23
+ const ARXIV_SLUG_PREFIX = "arxiv-auto-";
24
+ const MAX_AUTO_SOURCES = 10;
25
+ const DEFAULT_MAX_ITEMS = 20;
26
+
27
+ // ── Query building ──────────────────────────────────────────────
28
+
29
+ /**
30
+ * Build an arXiv query string from a list of keywords.
31
+ * Searches in title and abstract fields. Double quotes in keywords
32
+ * are stripped (not escaped) so the arXiv query stays valid.
33
+ * Example: ["transformer", "attention"] → 'ti:"transformer" OR abs:"transformer" OR ti:"attention" OR abs:"attention"'
34
+ */
35
+ export function buildArxivQuery(keywords: readonly string[]): string {
36
+ const terms = keywords.flatMap((kw) => {
37
+ const stripped = kw.replace(/"/g, "");
38
+ return [`ti:"${stripped}"`, `abs:"${stripped}"`];
39
+ });
40
+ return terms.join(" OR ");
41
+ }
42
+
43
+ /**
44
+ * Generate a slug from keywords. Uses a short hash of all keywords
45
+ * to avoid collisions when chunks share the same leading words or
46
+ * when keywords are non-ASCII (CJK, etc.).
47
+ * Example: ["WebAssembly", "WASM"] → "arxiv-auto-webassembly-wasm-a1b2"
48
+ */
49
+ export function keywordsToSlug(keywords: readonly string[]): string {
50
+ const latin = keywords
51
+ .map((kw) => slugify(kw, ""))
52
+ .filter((s) => s.length > 0)
53
+ .slice(0, 3)
54
+ .join("-");
55
+ // Short hash of ALL keywords ensures uniqueness even when the
56
+ // Latin portion is empty (non-ASCII) or identical across chunks.
57
+ const hash = crypto.createHash("sha256").update(keywords.join("|")).digest("hex").slice(0, 6);
58
+ const base = latin ? `${latin}-${hash}` : hash;
59
+ return `${ARXIV_SLUG_PREFIX}${base}`;
60
+ }
61
+
62
+ /**
63
+ * Generate a human-readable title from keywords.
64
+ */
65
+ function keywordsToTitle(keywords: readonly string[]): string {
66
+ return `arXiv: ${keywords.join(", ")}`;
67
+ }
68
+
69
+ // ── Discovery ───────────────────────────────────────────────────
70
+
71
+ export interface DiscoveryResult {
72
+ registered: string[];
73
+ skipped: string[];
74
+ reason: string | null;
75
+ }
76
+
77
+ /**
78
+ * Discover and register arXiv sources based on user interests.
79
+ * Groups all keywords into a single arXiv query source.
80
+ * Skips if the source already exists.
81
+ */
82
+ export async function discoverAndRegister(root?: string): Promise<DiscoveryResult> {
83
+ const base = root ?? workspacePath;
84
+ const profile = loadInterests(base);
85
+ if (!profile || profile.keywords.length === 0) {
86
+ return { registered: [], skipped: [], reason: "no keywords in interests" };
87
+ }
88
+
89
+ // Ensure sources directory exists
90
+ const dir = sourcesRoot(base);
91
+ fs.mkdirSync(dir, { recursive: true });
92
+
93
+ const existing = await listSources(base);
94
+ const existingSlugs = new Set(existing.map((s) => s.slug));
95
+
96
+ const registered: string[] = [];
97
+ const skipped: string[] = [];
98
+
99
+ // Strategy: group keywords into chunks of ~5 for separate sources,
100
+ // or put them all in one if few enough
101
+ const chunks = chunkKeywords(profile.keywords, 5);
102
+
103
+ for (const chunk of chunks.slice(0, MAX_AUTO_SOURCES)) {
104
+ const slug = keywordsToSlug(chunk);
105
+
106
+ if (existingSlugs.has(slug)) {
107
+ skipped.push(slug);
108
+ continue;
109
+ }
110
+
111
+ const query = buildArxivQuery(chunk);
112
+ const source: Source = {
113
+ slug,
114
+ title: keywordsToTitle(chunk),
115
+ url: `https://arxiv.org/search/?query=${encodeURIComponent(chunk.join(" "))}`,
116
+ fetcherKind: "arxiv",
117
+ fetcherParams: {
118
+ arxiv_query: query,
119
+ arxiv_sort: "submittedDate",
120
+ arxiv_order: "descending",
121
+ },
122
+ schedule: "daily",
123
+ categories: profile.categories.length > 0 ? profile.categories : ["papers"],
124
+ maxItemsPerFetch: DEFAULT_MAX_ITEMS,
125
+ addedAt: new Date().toISOString(),
126
+ notes: `Auto-registered from interests.json keywords: ${chunk.join(", ")}`,
127
+ };
128
+
129
+ try {
130
+ await writeSource(base, source);
131
+ registered.push(slug);
132
+ existingSlugs.add(slug);
133
+ log.info("arxiv-discovery", "registered arXiv source", { slug, query });
134
+ } catch (err) {
135
+ log.warn("arxiv-discovery", "failed to register source", {
136
+ slug,
137
+ error: String(err),
138
+ });
139
+ }
140
+ }
141
+
142
+ return { registered, skipped, reason: null };
143
+ }
144
+
145
+ function chunkKeywords(keywords: readonly string[], size: number): string[][] {
146
+ const chunks: string[][] = [];
147
+ for (let i = 0; i < keywords.length; i += size) {
148
+ chunks.push(keywords.slice(i, i + size) as string[]);
149
+ }
150
+ return chunks;
151
+ }
152
+
153
+ /**
154
+ * Remove auto-registered arXiv sources that no longer match
155
+ * any keyword in the current interests profile.
156
+ */
157
+ export async function pruneStaleAutoSources(root?: string): Promise<string[]> {
158
+ const base = root ?? workspacePath;
159
+ const profile = loadInterests(base);
160
+ const existing = await listSources(base);
161
+ const autoSources = existing.filter((s) => s.slug.startsWith(ARXIV_SLUG_PREFIX));
162
+
163
+ if (autoSources.length === 0) return [];
164
+
165
+ // If no profile at all, prune everything auto-registered
166
+ const currentKeywords = profile ? new Set(profile.keywords.map((k) => k.toLowerCase())) : new Set<string>();
167
+
168
+ const pruned: string[] = [];
169
+ for (const source of autoSources) {
170
+ const notes = (source.notes ?? "").toLowerCase();
171
+ const hasMatch = [...currentKeywords].some((kw) => notes.includes(kw));
172
+ if (!hasMatch && currentKeywords.size > 0) {
173
+ // Keywords changed — this source is stale but don't delete,
174
+ // just log. User can manually remove via manageSource.
175
+ log.info("arxiv-discovery", "stale auto-source detected", {
176
+ slug: source.slug,
177
+ });
178
+ pruned.push(source.slug);
179
+ }
180
+ }
181
+ return pruned;
182
+ }