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,112 @@
1
+ import path from "path";
2
+ import type { LogFormat, LogLevel } from "./types.js";
3
+ import { LEVEL_PRIORITY } from "./types.js";
4
+
5
+ export interface FileRotationConfig {
6
+ kind: "daily";
7
+ maxFiles: number;
8
+ }
9
+
10
+ export interface ConsoleSinkConfig {
11
+ enabled: boolean;
12
+ level: LogLevel;
13
+ format: LogFormat;
14
+ }
15
+
16
+ export interface FileSinkConfig {
17
+ enabled: boolean;
18
+ level: LogLevel;
19
+ format: LogFormat;
20
+ dir: string;
21
+ rotation: FileRotationConfig;
22
+ }
23
+
24
+ export interface TelemetrySinkConfig {
25
+ enabled: boolean;
26
+ level: LogLevel;
27
+ format: LogFormat;
28
+ }
29
+
30
+ export interface LoggerConfig {
31
+ sinks: {
32
+ console: ConsoleSinkConfig;
33
+ file: FileSinkConfig;
34
+ telemetry: TelemetrySinkConfig;
35
+ };
36
+ }
37
+
38
+ const DEFAULT_FILE_DIR = path.join("server", "system", "logs");
39
+ const DEFAULT_MAX_FILES = 14;
40
+
41
+ export const DEFAULT_CONFIG: LoggerConfig = {
42
+ sinks: {
43
+ console: { enabled: true, level: "info", format: "text" },
44
+ file: {
45
+ enabled: true,
46
+ level: "debug",
47
+ format: "json",
48
+ dir: DEFAULT_FILE_DIR,
49
+ rotation: { kind: "daily", maxFiles: DEFAULT_MAX_FILES },
50
+ },
51
+ telemetry: { enabled: false, level: "error", format: "json" },
52
+ },
53
+ };
54
+
55
+ function parseLevel(raw: string | undefined): LogLevel | undefined {
56
+ if (!raw) return undefined;
57
+ const normalized = raw.toLowerCase();
58
+ return normalized in LEVEL_PRIORITY ? (normalized as LogLevel) : undefined;
59
+ }
60
+
61
+ function parseFormat(raw: string | undefined): LogFormat | undefined {
62
+ if (!raw) return undefined;
63
+ const normalized = raw.toLowerCase();
64
+ return normalized === "text" || normalized === "json" ? normalized : undefined;
65
+ }
66
+
67
+ function parseBool(raw: string | undefined): boolean | undefined {
68
+ if (raw === undefined) return undefined;
69
+ const normalized = raw.toLowerCase();
70
+ if (normalized === "true" || normalized === "1" || normalized === "yes") return true;
71
+ if (normalized === "false" || normalized === "0" || normalized === "no") return false;
72
+ return undefined;
73
+ }
74
+
75
+ function parsePositiveInt(raw: string | undefined): number | undefined {
76
+ if (raw === undefined) return undefined;
77
+ const num = Number(raw);
78
+ return Number.isInteger(num) && num > 0 ? num : undefined;
79
+ }
80
+
81
+ export type Env = Partial<Record<string, string>>;
82
+
83
+ export function resolveConfig(env: Env): LoggerConfig {
84
+ const coarseLevel = parseLevel(env.LOG_LEVEL);
85
+ const consoleLevel = parseLevel(env.LOG_CONSOLE_LEVEL) ?? coarseLevel;
86
+ const fileLevel = parseLevel(env.LOG_FILE_LEVEL) ?? coarseLevel;
87
+
88
+ return {
89
+ sinks: {
90
+ console: {
91
+ enabled: parseBool(env.LOG_CONSOLE_ENABLED) ?? DEFAULT_CONFIG.sinks.console.enabled,
92
+ level: consoleLevel ?? DEFAULT_CONFIG.sinks.console.level,
93
+ format: parseFormat(env.LOG_CONSOLE_FORMAT) ?? DEFAULT_CONFIG.sinks.console.format,
94
+ },
95
+ file: {
96
+ enabled: parseBool(env.LOG_FILE_ENABLED) ?? DEFAULT_CONFIG.sinks.file.enabled,
97
+ level: fileLevel ?? DEFAULT_CONFIG.sinks.file.level,
98
+ format: parseFormat(env.LOG_FILE_FORMAT) ?? DEFAULT_CONFIG.sinks.file.format,
99
+ dir: env.LOG_FILE_DIR ?? DEFAULT_CONFIG.sinks.file.dir,
100
+ rotation: {
101
+ kind: "daily",
102
+ maxFiles: parsePositiveInt(env.LOG_FILE_MAX_FILES) ?? DEFAULT_CONFIG.sinks.file.rotation.maxFiles,
103
+ },
104
+ },
105
+ telemetry: {
106
+ enabled: parseBool(env.LOG_TELEMETRY_ENABLED) ?? DEFAULT_CONFIG.sinks.telemetry.enabled,
107
+ level: parseLevel(env.LOG_TELEMETRY_LEVEL) ?? DEFAULT_CONFIG.sinks.telemetry.level,
108
+ format: parseFormat(env.LOG_TELEMETRY_FORMAT) ?? DEFAULT_CONFIG.sinks.telemetry.format,
109
+ },
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,40 @@
1
+ import type { Formatter, LogRecord } from "./types.js";
2
+
3
+ function formatData(data: Record<string, unknown> | undefined): string {
4
+ if (!data) return "";
5
+ const parts: string[] = [];
6
+ for (const [key, val] of Object.entries(data)) {
7
+ parts.push(`${key}=${stringifyScalar(val)}`);
8
+ }
9
+ return parts.length ? ` ${parts.join(" ")}` : "";
10
+ }
11
+
12
+ function stringifyScalar(value: unknown): string {
13
+ if (value === null) return "null";
14
+ if (value === undefined) return "undefined";
15
+ if (typeof value === "string") {
16
+ return /\s/.test(value) ? JSON.stringify(value) : value;
17
+ }
18
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
19
+ try {
20
+ return JSON.stringify(value);
21
+ } catch {
22
+ return String(value);
23
+ }
24
+ }
25
+
26
+ export const formatText: Formatter = (record: LogRecord): string => {
27
+ const level = record.level.toUpperCase().padEnd(5);
28
+ return `${record.time} ${level} [${record.prefix}] ${record.message}${formatData(record.data)}`;
29
+ };
30
+
31
+ export const formatJson: Formatter = (record: LogRecord): string => {
32
+ const payload: Record<string, unknown> = {
33
+ time: record.time,
34
+ level: record.level,
35
+ prefix: record.prefix,
36
+ message: record.message,
37
+ };
38
+ if (record.data) payload.data = record.data;
39
+ return JSON.stringify(payload);
40
+ };
@@ -0,0 +1,53 @@
1
+ import type { LoggerConfig } from "./config.js";
2
+ import { resolveConfig } from "./config.js";
3
+ import { createConsoleSink, createFileSink, createTelemetrySink } from "./sinks.js";
4
+ import type { LogLevel, LogRecord, Sink } from "./types.js";
5
+ import { LEVEL_PRIORITY } from "./types.js";
6
+
7
+ export type { LogLevel, LogRecord } from "./types.js";
8
+ export type { LoggerConfig } from "./config.js";
9
+ export { resolveConfig, DEFAULT_CONFIG } from "./config.js";
10
+
11
+ export interface Logger {
12
+ error(prefix: string, message: string, data?: Record<string, unknown>): void;
13
+ warn(prefix: string, message: string, data?: Record<string, unknown>): void;
14
+ info(prefix: string, message: string, data?: Record<string, unknown>): void;
15
+ debug(prefix: string, message: string, data?: Record<string, unknown>): void;
16
+ }
17
+
18
+ export function createLogger(config: LoggerConfig): Logger {
19
+ const sinks: Sink[] = [];
20
+ if (config.sinks.console.enabled) sinks.push(createConsoleSink(config.sinks.console));
21
+ if (config.sinks.file.enabled) sinks.push(createFileSink(config.sinks.file));
22
+ if (config.sinks.telemetry.enabled) sinks.push(createTelemetrySink(config.sinks.telemetry));
23
+
24
+ function emit(level: LogLevel, prefix: string, message: string, data?: Record<string, unknown>): void {
25
+ const record: LogRecord = {
26
+ time: new Date().toISOString(),
27
+ level,
28
+ prefix,
29
+ message,
30
+ ...(data ? { data } : {}),
31
+ };
32
+ const recordPriority = LEVEL_PRIORITY[level];
33
+ for (const sink of sinks) {
34
+ if (recordPriority <= LEVEL_PRIORITY[sink.level]) {
35
+ try {
36
+ sink.write(record);
37
+ } catch {
38
+ // Per contract, sinks swallow their own errors; belt-and-suspenders here.
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ return {
45
+ error: (prefix, message, data) => emit("error", prefix, message, data),
46
+ warn: (prefix, message, data) => emit("warn", prefix, message, data),
47
+ info: (prefix, message, data) => emit("info", prefix, message, data),
48
+ debug: (prefix, message, data) => emit("debug", prefix, message, data),
49
+ };
50
+ }
51
+
52
+ // Default module-level logger resolved from process.env.
53
+ export const log: Logger = createLogger(resolveConfig(process.env));
@@ -0,0 +1,37 @@
1
+ import { mkdir, readdir, unlink } from "fs/promises";
2
+ import path from "path";
3
+
4
+ export function dailyFileName(date: Date): string {
5
+ const year = date.getUTCFullYear();
6
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
7
+ const day = String(date.getUTCDate()).padStart(2, "0");
8
+ return `server-${year}-${month}-${day}.log`;
9
+ }
10
+
11
+ const LOG_FILENAME_RE = /^server-\d{4}-\d{2}-\d{2}\.log$/;
12
+
13
+ export async function ensureDir(dir: string): Promise<void> {
14
+ await mkdir(dir, { recursive: true });
15
+ }
16
+
17
+ // List log files in `dir`, newest first (by file name — our names are
18
+ // ISO-sortable so string desc = chronological desc).
19
+ export async function listLogFiles(dir: string): Promise<string[]> {
20
+ let entries: string[];
21
+ try {
22
+ entries = await readdir(dir);
23
+ } catch {
24
+ return [];
25
+ }
26
+ return entries
27
+ .filter((fileName) => LOG_FILENAME_RE.test(fileName))
28
+ .sort()
29
+ .reverse();
30
+ }
31
+
32
+ export async function enforceMaxFiles(dir: string, maxFiles: number): Promise<void> {
33
+ if (maxFiles <= 0) return;
34
+ const files = await listLogFiles(dir);
35
+ const toDelete = files.slice(maxFiles);
36
+ await Promise.all(toDelete.map((fileName) => unlink(path.join(dir, fileName)).catch(() => {})));
37
+ }
@@ -0,0 +1,101 @@
1
+ import { appendFile } from "fs/promises";
2
+ import path from "path";
3
+ import type { ConsoleSinkConfig, FileSinkConfig, TelemetrySinkConfig } from "./config.js";
4
+ import { formatJson, formatText } from "./formatters.js";
5
+ import { dailyFileName, enforceMaxFiles, ensureDir } from "./rotation.js";
6
+ import type { Formatter, LogRecord, Sink } from "./types.js";
7
+
8
+ function pickFormatter(kind: "text" | "json"): Formatter {
9
+ return kind === "json" ? formatJson : formatText;
10
+ }
11
+
12
+ export function createConsoleSink(config: ConsoleSinkConfig): Sink {
13
+ const fmt = pickFormatter(config.format);
14
+ return {
15
+ name: "console",
16
+ level: config.level,
17
+ write(record: LogRecord) {
18
+ const line = fmt(record) + "\n";
19
+ const stream = record.level === "error" || record.level === "warn" ? process.stderr : process.stdout;
20
+ stream.write(line);
21
+ },
22
+ };
23
+ }
24
+
25
+ export interface FileSinkDeps {
26
+ now?: () => Date;
27
+ writeLine?: (filePath: string, line: string) => Promise<void>;
28
+ onError?: (err: unknown) => void;
29
+ }
30
+
31
+ // Factory for the rotating file sink. `deps` is only used by tests to
32
+ // inject a fake clock and an in-memory writer.
33
+ export function createFileSink(config: FileSinkConfig, deps: FileSinkDeps = {}): Sink {
34
+ const now = deps.now ?? (() => new Date());
35
+ const writeLine = deps.writeLine ?? ((filePath: string, line: string) => appendFile(filePath, line, "utf-8"));
36
+ const onError =
37
+ deps.onError ??
38
+ ((err: unknown) => {
39
+ // Fallback — never throw back into the caller.
40
+ console.error("[logger] file sink error:", err);
41
+ });
42
+
43
+ const fmt = pickFormatter(config.format);
44
+ let currentDateKey = "";
45
+ let currentPath = "";
46
+ // Per-sink write queue: chains all pending I/O so rotations can't
47
+ // interleave with a previous write.
48
+ let queue: Promise<void> = Promise.resolve();
49
+
50
+ function enqueue(operation: () => Promise<void>): void {
51
+ queue = queue.then(operation).catch(onError);
52
+ }
53
+
54
+ function dateKey(date: Date): string {
55
+ return `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`;
56
+ }
57
+
58
+ function maybeRotate(currentDate: Date): boolean {
59
+ const key = dateKey(currentDate);
60
+ if (key === currentDateKey) return false;
61
+ currentDateKey = key;
62
+ currentPath = path.join(config.dir, dailyFileName(currentDate));
63
+ enqueue(async () => {
64
+ await ensureDir(config.dir);
65
+ });
66
+ return true;
67
+ }
68
+
69
+ return {
70
+ name: "file",
71
+ level: config.level,
72
+ write(record: LogRecord) {
73
+ const nowDate = now();
74
+ const rotated = maybeRotate(nowDate);
75
+ const line = fmt(record) + "\n";
76
+ const filePath = currentPath;
77
+ enqueue(() => writeLine(filePath, line));
78
+ // Enforce retention AFTER the write so maxFiles counts include
79
+ // the file we just touched. Enforcing before rotation would
80
+ // leave N-1 existing files plus the fresh one (off-by-one).
81
+ if (rotated) {
82
+ enqueue(() => enforceMaxFiles(config.dir, config.rotation.maxFiles));
83
+ }
84
+ },
85
+ flush() {
86
+ return queue;
87
+ },
88
+ };
89
+ }
90
+
91
+ // Telemetry sink is a placeholder for a future remote-shipping
92
+ // implementation. Keeping the interface here so wiring is ready.
93
+ export function createTelemetrySink(config: TelemetrySinkConfig): Sink {
94
+ return {
95
+ name: "telemetry",
96
+ level: config.level,
97
+ write(__record: LogRecord) {
98
+ // no-op until a transport is added
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,29 @@
1
+ export type LogLevel = "error" | "warn" | "info" | "debug";
2
+
3
+ export type LogFormat = "text" | "json";
4
+
5
+ // Numeric priorities for level filtering. Lower = more important.
6
+ export const LEVEL_PRIORITY: Record<LogLevel, number> = {
7
+ error: 0,
8
+ warn: 1,
9
+ info: 2,
10
+ debug: 3,
11
+ };
12
+
13
+ export interface LogRecord {
14
+ time: string;
15
+ level: LogLevel;
16
+ prefix: string;
17
+ message: string;
18
+ data?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface Sink {
22
+ name: string;
23
+ level: LogLevel;
24
+ write(record: LogRecord): void;
25
+ // Drains any pending async I/O. Tests call this; production can ignore.
26
+ flush?(): Promise<void>;
27
+ }
28
+
29
+ export type Formatter = (record: LogRecord) => string;
@@ -0,0 +1,57 @@
1
+ // Shared date-formatting and date-validation helpers. Previously
2
+ // scattered across workspace/journal/paths.ts, journal/indexFile.ts,
3
+ // and workspace/tool-trace/writeSearch.ts.
4
+
5
+ /**
6
+ * YYYY-MM-DD in the LOCAL timezone. Used for journal daily paths
7
+ * and human-facing date labels — "what did I do on 2026-04-11"
8
+ * is a wall-clock question, not a UTC question.
9
+ */
10
+ export function toLocalIsoDate(input: Date | number): string {
11
+ const d = typeof input === "number" ? new Date(input) : input;
12
+ const y = d.getFullYear();
13
+ const m = String(d.getMonth() + 1).padStart(2, "0");
14
+ const day = String(d.getDate()).padStart(2, "0");
15
+ return `${y}-${m}-${day}`;
16
+ }
17
+
18
+ /**
19
+ * YYYY-MM-DD in UTC. Used for tool-trace search directories and
20
+ * any context where the date must not shift with the server's
21
+ * local timezone.
22
+ */
23
+ export function toUtcIsoDate(ts: Date): string {
24
+ const y = ts.getUTCFullYear();
25
+ const m = String(ts.getUTCMonth() + 1).padStart(2, "0");
26
+ const d = String(ts.getUTCDate()).padStart(2, "0");
27
+ return `${y}-${m}-${d}`;
28
+ }
29
+
30
+ /**
31
+ * Trim an ISO timestamp string to its YYYY-MM-DD date prefix.
32
+ * Example: "2026-04-11T08:30:00Z" → "2026-04-11".
33
+ */
34
+ export function isoDateOnly(iso: string): string {
35
+ return iso.slice(0, 10);
36
+ }
37
+
38
+ /**
39
+ * Strict validation of a YYYY-MM-DD string without regex —
40
+ * checks length, separator positions, and numeric segments.
41
+ * Does NOT validate month/day ranges (Feb 30 passes); that's the
42
+ * caller's or LLM's responsibility.
43
+ */
44
+ export function isValidIsoDate(s: string): boolean {
45
+ if (s.length !== 10) return false;
46
+ if (s[4] !== "-" || s[7] !== "-") return false;
47
+ return isNumeric(s.slice(0, 4)) && isNumeric(s.slice(5, 7)) && isNumeric(s.slice(8, 10));
48
+ }
49
+
50
+ function isNumeric(s: string): boolean {
51
+ if (s.length === 0) return false;
52
+ for (let i = 0; i < s.length; i++) {
53
+ const code = s.charCodeAt(i);
54
+ if (code < 48 || code > 57) return false;
55
+ }
56
+ return true;
57
+ }
@@ -0,0 +1,7 @@
1
+ // Shared error helpers. Use `errorMessage(err)` instead of inlining
2
+ // `err instanceof Error ? err.message : String(err)` — searching for
3
+ // one canonical helper is easier than grepping for the inline form.
4
+
5
+ export function errorMessage(err: unknown): string {
6
+ return err instanceof Error ? err.message : String(err);
7
+ }
@@ -0,0 +1,27 @@
1
+ // Helpers for server-side fetch() calls. The MCP stdio bridge
2
+ // (`server/agent/mcp-server.ts`) makes multiple fetch calls to
3
+ // the host Express server and repeated the same error-extraction
4
+ // pattern at every call site.
5
+
6
+ import { isRecord } from "./types.js";
7
+
8
+ /**
9
+ * Extract a human-readable error string from a non-ok fetch Response.
10
+ *
11
+ * Tries to parse the body as `{ error: string }` (the shape every
12
+ * MulmoClaude `/api/*` endpoint returns on failure). Falls back to
13
+ * `"HTTP <status>"` when the body isn't JSON, isn't a plain object,
14
+ * or doesn't contain an `error` string field.
15
+ */
16
+ export async function extractFetchError(res: Response): Promise<string> {
17
+ let body: unknown;
18
+ try {
19
+ body = await res.json();
20
+ } catch {
21
+ return `HTTP ${res.status}`;
22
+ }
23
+ if (isRecord(body) && typeof body.error === "string") {
24
+ return body.error;
25
+ }
26
+ return `HTTP ${res.status}`;
27
+ }
@@ -0,0 +1,125 @@
1
+ // Atomic file-write primitives. rename(2) is atomic on POSIX; Node's
2
+ // Windows implementation falls back to copy+unlink which is still
3
+ // safer than truncating the target in place. Readers always see
4
+ // either the old file or the new file — never a half-written one.
5
+ //
6
+ // Moved from server/utils/file.ts (issue #366 Phase 1). The old
7
+ // file re-exports these for backwards compat.
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { randomUUID } from "crypto";
12
+
13
+ export interface WriteAtomicOptions {
14
+ /** File mode for the final file (e.g. `0o600` for secrets). */
15
+ mode?: number;
16
+ /**
17
+ * If true, append a randomUUID to the tmp filename to avoid
18
+ * collisions at the OS level when multiple writers target the same
19
+ * final path concurrently (e.g. chat-index has this concern).
20
+ * Default false — a single `${path}.tmp` is fine for most callers.
21
+ */
22
+ uniqueTmp?: boolean;
23
+ }
24
+
25
+ // ── Windows rename retry ────────────────────────────────────────
26
+ //
27
+ // On Windows, `rename` (MoveFileEx with MOVEFILE_REPLACE_EXISTING) can
28
+ // transiently fail with EPERM or EBUSY when antivirus / Search
29
+ // Indexer / Defender momentarily holds a handle on the tmp file or
30
+ // destination file. The failure window is tiny (usually <100ms) and
31
+ // the rename succeeds on a retry.
32
+ //
33
+ // On POSIX, `rename` is atomic and overwrites unconditionally. EPERM
34
+ // there means a real permission problem (read-only filesystem, sticky
35
+ // bit, cross-device link) — retrying wouldn't help and would only add
36
+ // latency before the inevitable throw. So the retry loop is gated to
37
+ // Windows.
38
+ const IS_WINDOWS = process.platform === "win32";
39
+ const RENAME_RETRY_DELAYS_MS = [30, 100, 300] as const;
40
+
41
+ function hasErrnoCode(err: unknown): err is { code: string } {
42
+ return typeof err === "object" && err !== null && "code" in err && typeof (err as { code: unknown }).code === "string";
43
+ }
44
+
45
+ function isTransientRenameError(err: unknown): boolean {
46
+ if (!IS_WINDOWS || !hasErrnoCode(err)) return false;
47
+ return err.code === "EPERM" || err.code === "EBUSY" || err.code === "EACCES";
48
+ }
49
+
50
+ async function renameWithWindowsRetry(from: string, to: string): Promise<void> {
51
+ for (const delayMs of RENAME_RETRY_DELAYS_MS) {
52
+ try {
53
+ await fs.promises.rename(from, to);
54
+ return;
55
+ } catch (err) {
56
+ if (!isTransientRenameError(err)) throw err;
57
+ await new Promise((r) => setTimeout(r, delayMs));
58
+ }
59
+ }
60
+ // Final attempt — let any error propagate.
61
+ await fs.promises.rename(from, to);
62
+ }
63
+
64
+ // Sync sleep that parks the thread instead of burning CPU. Only
65
+ // invoked on the transient-Windows-rename path, so the total worst-
66
+ // case block is the sum of RENAME_RETRY_DELAYS_MS (~430ms) and only
67
+ // triggers under AV/indexer contention.
68
+ const SYNC_SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
69
+ function sleepSync(ms: number): void {
70
+ Atomics.wait(SYNC_SLEEP_BUF, 0, 0, ms);
71
+ }
72
+
73
+ function renameSyncWithWindowsRetry(from: string, to: string): void {
74
+ for (const delayMs of RENAME_RETRY_DELAYS_MS) {
75
+ try {
76
+ fs.renameSync(from, to);
77
+ return;
78
+ } catch (err) {
79
+ if (!isTransientRenameError(err)) throw err;
80
+ sleepSync(delayMs);
81
+ }
82
+ }
83
+ fs.renameSync(from, to);
84
+ }
85
+
86
+ /**
87
+ * Write `content` to `filePath` atomically. The parent directory is
88
+ * created if missing. The tmp file is cleaned up on failure so a
89
+ * crashed partial write can't wedge the next try.
90
+ */
91
+ export async function writeFileAtomic(filePath: string, content: string, opts: WriteAtomicOptions = {}): Promise<void> {
92
+ const tmp = opts.uniqueTmp ? `${filePath}.${randomUUID()}.tmp` : `${filePath}.tmp`;
93
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
94
+ try {
95
+ await fs.promises.writeFile(tmp, content, {
96
+ encoding: "utf-8",
97
+ mode: opts.mode,
98
+ });
99
+ await renameWithWindowsRetry(tmp, filePath);
100
+ } catch (err) {
101
+ await fs.promises.unlink(tmp).catch(() => {});
102
+ throw err;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Synchronous atomic write for callers that need it (e.g. server
108
+ * startup, config saves that must complete before the next line).
109
+ * Same contract as `writeFileAtomic` but blocking.
110
+ */
111
+ export function writeFileAtomicSync(filePath: string, content: string, opts: WriteAtomicOptions = {}): void {
112
+ const tmp = opts.uniqueTmp ? `${filePath}.${randomUUID()}.tmp` : `${filePath}.tmp`;
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ try {
115
+ fs.writeFileSync(tmp, content, { encoding: "utf-8", mode: opts.mode });
116
+ renameSyncWithWindowsRetry(tmp, filePath);
117
+ } catch (err) {
118
+ try {
119
+ fs.unlinkSync(tmp);
120
+ } catch {
121
+ // best-effort cleanup
122
+ }
123
+ throw err;
124
+ }
125
+ }
@@ -0,0 +1,20 @@
1
+ // Domain I/O: HTML scratch buffer
2
+ // artifacts/html-scratch/current.html
3
+ //
4
+ // Optional `root` for test DI.
5
+
6
+ import path from "node:path";
7
+ import { WORKSPACE_DIRS } from "../../workspace/paths.js";
8
+ import { workspacePath } from "../../workspace/paths.js";
9
+ import { readTextUnder, writeTextUnder } from "./workspace-io.js";
10
+
11
+ const HTML_REL = path.posix.join(WORKSPACE_DIRS.html, "current.html");
12
+ const root = (r?: string) => r ?? workspacePath;
13
+
14
+ export async function readCurrentHtml(r?: string): Promise<string | null> {
15
+ return readTextUnder(root(r), HTML_REL);
16
+ }
17
+
18
+ export async function writeCurrentHtml(html: string, r?: string): Promise<void> {
19
+ await writeTextUnder(root(r), HTML_REL, html);
20
+ }
@@ -0,0 +1,66 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { WORKSPACE_DIRS, WORKSPACE_PATHS } from "../../workspace/paths.js";
5
+ import { resolveWithinRoot } from "./safe.js";
6
+
7
+ const IMAGES_DIR = WORKSPACE_PATHS.images;
8
+
9
+ // Cached realpath of the images directory. resolveWithinRoot requires
10
+ // its root argument to be a realpath so symlinks are handled correctly.
11
+ let imagesDirReal: string | null = null;
12
+
13
+ async function ensureImagesDir(): Promise<string> {
14
+ if (imagesDirReal) return imagesDirReal;
15
+ await fs.mkdir(IMAGES_DIR, { recursive: true });
16
+ imagesDirReal = await fs.realpath(IMAGES_DIR);
17
+ return imagesDirReal;
18
+ }
19
+
20
+ // Resolve a workspace-relative image path (e.g. "images/abc123.png")
21
+ // into an absolute path that is guaranteed to be inside the images
22
+ // directory. Throws on traversal attempts or non-existent files.
23
+ async function safeResolve(relativePath: string): Promise<string> {
24
+ const root = await ensureImagesDir();
25
+ // Strip the leading "images/" prefix so the caller can pass either
26
+ // "images/abc.png" (the stored form) or just "abc.png".
27
+ const name = relativePath.replace(new RegExp(`^${WORKSPACE_DIRS.images}/`), "");
28
+ const result = resolveWithinRoot(root, name);
29
+ if (!result) {
30
+ throw new Error(`path traversal rejected: ${relativePath}`);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ /** Save raw base64 (no data URI prefix) as a PNG file. Returns the workspace-relative path. */
36
+ export async function saveImage(base64Data: string): Promise<string> {
37
+ await ensureImagesDir();
38
+ const id = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
39
+ const filename = `${id}.png`;
40
+ const absPath = path.join(IMAGES_DIR, filename);
41
+ await fs.writeFile(absPath, Buffer.from(base64Data, "base64"));
42
+ return path.posix.join(WORKSPACE_DIRS.images, filename);
43
+ }
44
+
45
+ /** Overwrite an existing image file. The relativePath must start with "images/". */
46
+ export async function overwriteImage(relativePath: string, base64Data: string): Promise<void> {
47
+ const absPath = await safeResolve(relativePath);
48
+ await fs.writeFile(absPath, Buffer.from(base64Data, "base64"));
49
+ }
50
+
51
+ /** Read an image file and return raw base64 (no data URI prefix). */
52
+ export async function loadImageBase64(relativePath: string): Promise<string> {
53
+ const absPath = await safeResolve(relativePath);
54
+ const buf = await fs.readFile(absPath);
55
+ return buf.toString("base64");
56
+ }
57
+
58
+ /** Convert a data URI to raw base64. */
59
+ export function stripDataUri(dataUri: string): string {
60
+ return dataUri.replace(/^data:image\/[^;]+;base64,/, "");
61
+ }
62
+
63
+ /** Check if a string is a file reference (not a data URI). */
64
+ export function isImagePath(value: string): boolean {
65
+ return value.startsWith(`${WORKSPACE_DIRS.images}/`) && value.endsWith(".png");
66
+ }