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,250 @@
1
+ // Driver for persisting built-in Claude tool events to the session
2
+ // jsonl (#194). Called from routes/agent.ts for each tool_call and
3
+ // tool_call_result event. Fire-and-forget semantics: all failures are
4
+ // caught and log.warn'd with the `tool-trace` prefix.
5
+
6
+ import { appendFile } from "node:fs/promises";
7
+ import { log } from "../../system/logger/index.js";
8
+ import { WEB_SEARCH_TOOL_NAME, classifyToolResult } from "./classify.js";
9
+ import { writeSearchResult } from "./writeSearch.js";
10
+ import { EVENT_TYPES } from "../../../src/types/events.js";
11
+ import { isNonEmptyString, isRecord } from "../../utils/types.js";
12
+
13
+ export interface ToolCallEvent {
14
+ type: typeof EVENT_TYPES.toolCall;
15
+ toolUseId: string;
16
+ toolName: string;
17
+ args: unknown;
18
+ }
19
+
20
+ export interface ToolCallResultEvent {
21
+ type: typeof EVENT_TYPES.toolCallResult;
22
+ toolUseId: string;
23
+ content: string;
24
+ }
25
+
26
+ export type ToolTraceEvent = ToolCallEvent | ToolCallResultEvent;
27
+
28
+ export interface CachedCall {
29
+ toolName: string;
30
+ args: unknown;
31
+ }
32
+
33
+ export type ArgsCache = Map<string, CachedCall>;
34
+
35
+ export interface RecordToolEventDeps {
36
+ workspaceRoot: string;
37
+ chatSessionId: string;
38
+ resultsFilePath: string;
39
+ argsCache: ArgsCache;
40
+ now?: () => Date;
41
+ // Test hooks.
42
+ appendLine?: (filePath: string, line: string) => Promise<void>;
43
+ saveSearch?: typeof writeSearchResult;
44
+ }
45
+
46
+ export function createArgsCache(): ArgsCache {
47
+ return new Map<string, CachedCall>();
48
+ }
49
+
50
+ const defaultAppendLine = (filePath: string, line: string) => appendFile(filePath, line, "utf-8");
51
+
52
+ export async function recordToolEvent(event: ToolTraceEvent, deps: RecordToolEventDeps): Promise<void> {
53
+ try {
54
+ if (event.type === EVENT_TYPES.toolCall) {
55
+ await handleToolCall(event, deps);
56
+ return;
57
+ }
58
+ await handleToolCallResult(event, deps);
59
+ } catch (err) {
60
+ log.warn("tool-trace", "recordToolEvent failed", {
61
+ type: event.type,
62
+ toolUseId: event.toolUseId,
63
+ error: String(err),
64
+ });
65
+ }
66
+ }
67
+
68
+ async function handleToolCall(event: ToolCallEvent, deps: RecordToolEventDeps): Promise<void> {
69
+ deps.argsCache.set(event.toolUseId, {
70
+ toolName: event.toolName,
71
+ args: event.args,
72
+ });
73
+ const now = (deps.now ?? (() => new Date()))();
74
+ const record = {
75
+ source: "tool",
76
+ type: EVENT_TYPES.toolCall,
77
+ toolUseId: event.toolUseId,
78
+ toolName: event.toolName,
79
+ args: event.args,
80
+ ts: now.toISOString(),
81
+ };
82
+ await appendRecord(deps, record);
83
+ logToolCall(event);
84
+ }
85
+
86
+ // Emit an info-level log when a tool fires. WebSearch and WebFetch
87
+ // get a little extra context (the query / URL) because those are the
88
+ // two tools whose progress users most often want to watch in real
89
+ // time.
90
+ function logToolCall(event: ToolCallEvent): void {
91
+ if (event.toolName === "WebSearch") {
92
+ const query = extractQuery(event.args);
93
+ log.info("tool-trace", "web_search starting", {
94
+ toolUseId: event.toolUseId,
95
+ query: query ?? "<missing>",
96
+ });
97
+ return;
98
+ }
99
+ if (event.toolName === "WebFetch") {
100
+ const url = extractUrl(event.args);
101
+ log.info("tool-trace", "web_fetch starting", {
102
+ toolUseId: event.toolUseId,
103
+ url: url ?? "<missing>",
104
+ });
105
+ return;
106
+ }
107
+ log.debug("tool-trace", "tool_call", {
108
+ toolUseId: event.toolUseId,
109
+ toolName: event.toolName,
110
+ });
111
+ }
112
+
113
+ function extractUrl(args: unknown): string | null {
114
+ if (!isRecord(args)) return null;
115
+ const record = args;
116
+ const raw = record.url;
117
+ return isNonEmptyString(raw) ? raw : null;
118
+ }
119
+
120
+ // Emit progress on the result side. For WebSearch we specifically
121
+ // log the saved `contentRef` so operators can follow the breadcrumb
122
+ // from the server log straight to the on-disk search file.
123
+ function logToolCallResult(
124
+ toolName: string,
125
+ event: ToolCallResultEvent,
126
+ classification: { kind: "pointer"; contentRef: string } | { kind: "inline"; content: string; truncated: boolean },
127
+ searchContentRef: string | undefined,
128
+ ): void {
129
+ if (toolName === "WebSearch" && searchContentRef) {
130
+ log.info("tool-trace", "web_search saved", {
131
+ toolUseId: event.toolUseId,
132
+ contentRef: searchContentRef,
133
+ bodyLen: event.content.length,
134
+ });
135
+ return;
136
+ }
137
+ if (toolName === "WebSearch") {
138
+ // Save failed earlier → we already emitted a warn; record
139
+ // inline-fallback landing at info so the pair stays matched.
140
+ log.info("tool-trace", "web_search inlined (save failed)", {
141
+ toolUseId: event.toolUseId,
142
+ bodyLen: event.content.length,
143
+ });
144
+ return;
145
+ }
146
+ if (classification.kind === "pointer") {
147
+ log.debug("tool-trace", "tool_call_result pointer", {
148
+ toolUseId: event.toolUseId,
149
+ toolName,
150
+ contentRef: classification.contentRef,
151
+ });
152
+ return;
153
+ }
154
+ log.debug("tool-trace", "tool_call_result inline", {
155
+ toolUseId: event.toolUseId,
156
+ toolName,
157
+ bodyLen: event.content.length,
158
+ truncated: classification.truncated,
159
+ });
160
+ }
161
+
162
+ async function handleToolCallResult(event: ToolCallResultEvent, deps: RecordToolEventDeps): Promise<void> {
163
+ const now = (deps.now ?? (() => new Date()))();
164
+ const cached = deps.argsCache.get(event.toolUseId);
165
+ const toolName = cached?.toolName ?? "";
166
+ const args = cached?.args ?? {};
167
+
168
+ const searchContentRef = await maybeWriteSearch({
169
+ toolName,
170
+ args,
171
+ content: event.content,
172
+ chatSessionId: deps.chatSessionId,
173
+ workspaceRoot: deps.workspaceRoot,
174
+ now,
175
+ saveSearch: deps.saveSearch,
176
+ });
177
+
178
+ const classification = classifyToolResult({
179
+ toolName,
180
+ args,
181
+ content: event.content,
182
+ searchContentRef,
183
+ });
184
+
185
+ const base = {
186
+ source: "tool",
187
+ type: EVENT_TYPES.toolCallResult,
188
+ toolUseId: event.toolUseId,
189
+ toolName,
190
+ ts: now.toISOString(),
191
+ };
192
+ const record =
193
+ classification.kind === "pointer"
194
+ ? { ...base, contentRef: classification.contentRef }
195
+ : {
196
+ ...base,
197
+ content: classification.content,
198
+ truncated: classification.truncated,
199
+ };
200
+ await appendRecord(deps, record);
201
+ logToolCallResult(toolName, event, classification, searchContentRef);
202
+
203
+ // Release the cache entry once consumed so long-lived sessions
204
+ // don't accumulate stale tool_use ids.
205
+ deps.argsCache.delete(event.toolUseId);
206
+ }
207
+
208
+ interface MaybeWriteSearchInputs {
209
+ toolName: string;
210
+ args: unknown;
211
+ content: string;
212
+ chatSessionId: string;
213
+ workspaceRoot: string;
214
+ now: Date;
215
+ saveSearch?: typeof writeSearchResult;
216
+ }
217
+
218
+ async function maybeWriteSearch(inputs: MaybeWriteSearchInputs): Promise<string | undefined> {
219
+ if (inputs.toolName !== WEB_SEARCH_TOOL_NAME) return undefined;
220
+ const query = extractQuery(inputs.args);
221
+ if (!query) return undefined;
222
+ const save = inputs.saveSearch ?? writeSearchResult;
223
+ try {
224
+ return await save({
225
+ workspaceRoot: inputs.workspaceRoot,
226
+ query,
227
+ sessionId: inputs.chatSessionId,
228
+ ts: inputs.now,
229
+ resultBody: inputs.content,
230
+ });
231
+ } catch (err) {
232
+ log.warn("tool-trace", "writeSearchResult failed", {
233
+ error: String(err),
234
+ });
235
+ return undefined;
236
+ }
237
+ }
238
+
239
+ function extractQuery(args: unknown): string | null {
240
+ if (!isRecord(args)) return null;
241
+ const record = args;
242
+ const raw = record.query;
243
+ if (typeof raw !== "string" || raw.length === 0) return null;
244
+ return raw;
245
+ }
246
+
247
+ async function appendRecord(deps: RecordToolEventDeps, record: object): Promise<void> {
248
+ const append = deps.appendLine ?? defaultAppendLine;
249
+ await append(deps.resultsFilePath, JSON.stringify(record) + "\n");
250
+ }
@@ -0,0 +1,98 @@
1
+ // Saves WebSearch results as a durable markdown file under
2
+ // `workspace/conversations/searches/YYYY-MM-DD/<slug>-<hash>.md` and
3
+ // returns the workspace-relative path for use as a jsonl `contentRef`.
4
+ //
5
+ // The pure helpers (slug / hash / path / content template) are
6
+ // exported for unit tests; the side-effecting function at the end is
7
+ // a thin wrapper.
8
+
9
+ import fsp from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { createHash } from "node:crypto";
12
+ import { slugify } from "../../utils/slug.js";
13
+ import { toUtcIsoDate } from "../../utils/date.js";
14
+ import { WORKSPACE_DIRS } from "../paths.js";
15
+
16
+ export const SEARCHES_DIR = WORKSPACE_DIRS.searches;
17
+ const SEARCH_HASH_LEN = 8;
18
+ const MAX_QUERY_SLUG_CHARS = 40;
19
+
20
+ // Re-export for backwards compatibility with callers that imported
21
+ // from this module. The function is now in utils/date.ts.
22
+ export { toUtcIsoDate as formatSearchDateDir } from "../../utils/date.js";
23
+
24
+ export function computeSearchHash(query: string, sessionId: string, ts: Date): string {
25
+ return createHash("sha256").update(`${query}\n${sessionId}\n${ts.toISOString()}`, "utf-8").digest("base64url").slice(0, SEARCH_HASH_LEN);
26
+ }
27
+
28
+ export interface SearchPathInputs {
29
+ query: string;
30
+ sessionId: string;
31
+ ts: Date;
32
+ }
33
+
34
+ // Returns the workspace-relative path (POSIX slashes) where the search
35
+ // file should live, e.g. "conversations/searches/2026-04-13/foo-abc12345.md".
36
+ export function computeSearchRelPath(inputs: SearchPathInputs): string {
37
+ const slug = slugify(inputs.query, "search", MAX_QUERY_SLUG_CHARS);
38
+ const hash = computeSearchHash(inputs.query, inputs.sessionId, inputs.ts);
39
+ const dateDir = toUtcIsoDate(inputs.ts);
40
+ return path.posix.join(SEARCHES_DIR, dateDir, `${slug}-${hash}.md`);
41
+ }
42
+
43
+ export interface SearchContentInputs {
44
+ query: string;
45
+ sessionId: string;
46
+ ts: Date;
47
+ resultBody: string;
48
+ }
49
+
50
+ // Build the on-disk markdown body: YAML frontmatter with
51
+ // machine-readable metadata, then a human-readable heading, then the
52
+ // raw search result body verbatim.
53
+ export function buildSearchMarkdown(inputs: SearchContentInputs): string {
54
+ const { query, sessionId, ts, resultBody } = inputs;
55
+ const body = resultBody.endsWith("\n") ? resultBody : `${resultBody}\n`;
56
+ return ["---", `query: ${jsonStringSafe(query)}`, `sessionId: ${sessionId}`, `ts: ${ts.toISOString()}`, "---", "", `# Search: ${query}`, "", body].join("\n");
57
+ }
58
+
59
+ // Quote a string for YAML only when it could otherwise be
60
+ // misinterpreted (contains a colon, hash, or leading/trailing space).
61
+ // Cheap and good enough for a machine-authored frontmatter.
62
+ function jsonStringSafe(s: string): string {
63
+ const needsQuote = /[:#\n]/.test(s) || s !== s.trim();
64
+ return needsQuote ? JSON.stringify(s) : s;
65
+ }
66
+
67
+ export interface WriteSearchInputs extends SearchContentInputs {
68
+ workspaceRoot: string;
69
+ }
70
+
71
+ export interface WriteSearchDeps {
72
+ mkdir: (dir: string) => Promise<void>;
73
+ writeFile: (p: string, content: string) => Promise<void>;
74
+ }
75
+
76
+ const defaultDeps: WriteSearchDeps = {
77
+ mkdir: async (dir) => {
78
+ await fsp.mkdir(dir, { recursive: true });
79
+ },
80
+ writeFile: (p, content) => fsp.writeFile(p, content, "utf-8"),
81
+ };
82
+
83
+ /**
84
+ * Save the search result to disk and return the workspace-relative
85
+ * path that should be used as the jsonl `contentRef`.
86
+ */
87
+ export async function writeSearchResult(inputs: WriteSearchInputs, deps: Partial<WriteSearchDeps> = {}): Promise<string> {
88
+ const d: WriteSearchDeps = { ...defaultDeps, ...deps };
89
+ const relPath = computeSearchRelPath({
90
+ query: inputs.query,
91
+ sessionId: inputs.sessionId,
92
+ ts: inputs.ts,
93
+ });
94
+ const absPath = path.join(inputs.workspaceRoot, relPath);
95
+ await d.mkdir(path.dirname(absPath));
96
+ await d.writeFile(absPath, buildSearchMarkdown(inputs));
97
+ return relPath;
98
+ }
@@ -0,0 +1,107 @@
1
+ // Driver for wiki page session-backlink appendix (#109).
2
+ //
3
+ // The agent route calls `maybeAppendWikiBacklinks({ chatSessionId,
4
+ // turnStartedAt, ... })` from its `finally` block — fire-and-forget.
5
+ // This module:
6
+ //
7
+ // - scans `wiki/pages/*.md` for files modified during this turn
8
+ // - appends a session backlink to each qualifying page
9
+ // - swallows all errors with a log.warn so nothing ever bubbles
10
+ // back into the request handler
11
+ //
12
+ // Mtime-based detection sounds fragile but works well here because
13
+ // MulmoClaude is single-user / single-process and the turn scope is
14
+ // strictly <= one agent run. If two sessions ever overlap on the
15
+ // same page the later turn will simply add a second bullet — which
16
+ // is the intended behaviour.
17
+
18
+ import fsp from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { workspacePath as defaultWorkspacePath } from "../workspace.js";
21
+ import { WORKSPACE_DIRS } from "../paths.js";
22
+ import { log } from "../../system/logger/index.js";
23
+ import { updateSessionBacklinks } from "./sessionBacklinks.js";
24
+ import { ONE_SECOND_MS } from "../../utils/time.js";
25
+
26
+ // Small tolerance for filesystem mtime granularity (some filesystems
27
+ // only record to 1-second precision). Without this, a page written
28
+ // within the same millisecond as turnStartedAt could be skipped.
29
+ const MTIME_TOLERANCE_MS = ONE_SECOND_MS;
30
+
31
+ export interface WikiBacklinksDeps {
32
+ readdir: (dir: string) => Promise<string[]>;
33
+ stat: (p: string) => Promise<{ mtimeMs: number }>;
34
+ readFile: (p: string) => Promise<string>;
35
+ writeFile: (p: string, content: string) => Promise<void>;
36
+ }
37
+
38
+ const defaultDeps: WikiBacklinksDeps = {
39
+ readdir: (dir) => fsp.readdir(dir),
40
+ stat: (p) => fsp.stat(p),
41
+ readFile: (p) => fsp.readFile(p, "utf-8"),
42
+ writeFile: (p, content) => fsp.writeFile(p, content, "utf-8"),
43
+ };
44
+
45
+ export interface MaybeAppendWikiBacklinksOptions {
46
+ chatSessionId: string;
47
+ turnStartedAt: number;
48
+ workspaceRoot?: string;
49
+ deps?: Partial<WikiBacklinksDeps>;
50
+ }
51
+
52
+ export async function maybeAppendWikiBacklinks(opts: MaybeAppendWikiBacklinksOptions): Promise<void> {
53
+ if (!opts.chatSessionId) return;
54
+ const workspaceRoot = opts.workspaceRoot ?? defaultWorkspacePath;
55
+ const deps: WikiBacklinksDeps = { ...defaultDeps, ...(opts.deps ?? {}) };
56
+ const pagesDir = path.join(workspaceRoot, WORKSPACE_DIRS.wikiPages);
57
+
58
+ const files = await listPageFiles(pagesDir, deps);
59
+ if (files.length === 0) return;
60
+
61
+ const threshold = opts.turnStartedAt - MTIME_TOLERANCE_MS;
62
+ for (const fileName of files) {
63
+ await processOneFile(pagesDir, fileName, opts.chatSessionId, threshold, deps);
64
+ }
65
+ }
66
+
67
+ async function listPageFiles(pagesDir: string, deps: WikiBacklinksDeps): Promise<string[]> {
68
+ try {
69
+ const entries = await deps.readdir(pagesDir);
70
+ return entries.filter((name) => name.endsWith(".md"));
71
+ } catch {
72
+ // `wiki/pages/` may not exist yet — first run, empty workspace.
73
+ // Not an error.
74
+ return [];
75
+ }
76
+ }
77
+
78
+ async function processOneFile(pagesDir: string, fileName: string, sessionId: string, mtimeThreshold: number, deps: WikiBacklinksDeps): Promise<void> {
79
+ const fullPath = path.join(pagesDir, fileName);
80
+ try {
81
+ const st = await deps.stat(fullPath);
82
+ if (st.mtimeMs < mtimeThreshold) return;
83
+
84
+ const content = await deps.readFile(fullPath);
85
+ // Compute the relative path from the wiki page's directory to
86
+ // the chat jsonl. Layout grouped both under `data/wiki/pages/`
87
+ // and `conversations/chat/` post-#284, so the href is no longer
88
+ // a fixed `../../chat/…` — derive from the constants.
89
+ const workspaceRoot = path.resolve(pagesDir, "..", "..", "..");
90
+ const chatFileAbs = path.join(workspaceRoot, WORKSPACE_DIRS.chat, `${sessionId}.jsonl`);
91
+ // Markdown link targets are URL-ish and must use forward slashes
92
+ // even on Windows, where `path.relative` returns backslashes.
93
+ const linkHref = path.relative(path.dirname(fullPath), chatFileAbs).split(path.sep).join("/");
94
+ const updated = updateSessionBacklinks(content, sessionId, linkHref);
95
+ if (updated === content) return;
96
+
97
+ await deps.writeFile(fullPath, updated);
98
+ log.debug("wiki-backlinks", "appended", {
99
+ file: `wiki/pages/${fileName}`,
100
+ });
101
+ } catch (err) {
102
+ log.warn("wiki-backlinks", "failed to update page", {
103
+ file: `wiki/pages/${fileName}`,
104
+ error: String(err),
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,144 @@
1
+ // Pure helpers for wiki page session-backlink appendix (#109).
2
+ //
3
+ // Each wiki page that was touched during a chat session gets a small
4
+ // machine-managed appendix at the end listing the sessions that
5
+ // modified it. The appendix is demarcated by an HTML comment so
6
+ // renderers don't show the marker and our parser can find the
7
+ // boundary without a regex:
8
+ //
9
+ // ... user-authored page body ...
10
+ //
11
+ // <!-- journal-session-backlinks -->
12
+ // ## History
13
+ //
14
+ // - [session 3e0382cb](../../chat/3e0382cb-f02f-4f5b-a9a3-a71e50d7ad0c.jsonl)
15
+ // - [session 4d7f5377](../../chat/4d7f5377-1bac-460c-8ec5-ea054fa0492d.jsonl)
16
+ //
17
+ // Contract: `updateSessionBacklinks(existingContent, sessionId,
18
+ // linkHref)` returns the content that should be written back. If the
19
+ // sessionId is already listed in the appendix, the return value is
20
+ // byte-for-byte equal to `existingContent` so the caller can skip the
21
+ // write.
22
+
23
+ export const BACKLINKS_MARKER = "<!-- journal-session-backlinks -->";
24
+ const HISTORY_HEADING = "## History";
25
+ const SESSION_ID_SHORT_LEN = 8;
26
+
27
+ /**
28
+ * Append a backlink for `sessionId` to the appendix of `existingContent`.
29
+ *
30
+ * If the appendix doesn't exist yet it is created at the end of the
31
+ * content. If it exists and already lists `sessionId` the content is
32
+ * returned unchanged (idempotent).
33
+ */
34
+ export function updateSessionBacklinks(existingContent: string, sessionId: string, linkHref: string): string {
35
+ if (!sessionId) return existingContent;
36
+
37
+ const markerIdx = existingContent.indexOf(BACKLINKS_MARKER);
38
+ if (markerIdx === -1) {
39
+ return appendFreshAppendix(existingContent, sessionId, linkHref);
40
+ }
41
+
42
+ const bodyBeforeAppendix = existingContent.slice(0, markerIdx);
43
+ const appendixSection = existingContent.slice(markerIdx);
44
+ const existingSessionIds = extractSessionIdsFromAppendix(appendixSection);
45
+ if (existingSessionIds.has(sessionId)) return existingContent;
46
+
47
+ const newBullet = buildBullet(sessionId, linkHref);
48
+ const updatedAppendix = appendBulletToAppendix(appendixSection, newBullet);
49
+ return bodyBeforeAppendix + updatedAppendix;
50
+ }
51
+
52
+ function buildBullet(sessionId: string, linkHref: string): string {
53
+ const short = sessionId.slice(0, SESSION_ID_SHORT_LEN) || sessionId;
54
+ return `- [session ${short}](${linkHref})`;
55
+ }
56
+
57
+ function appendFreshAppendix(body: string, sessionId: string, linkHref: string): string {
58
+ const bullet = buildBullet(sessionId, linkHref);
59
+ const separator = body.length === 0 || body.endsWith("\n") ? "" : "\n";
60
+ const leadingBlank = body.length === 0 ? "" : "\n";
61
+ return `${body}${separator}${leadingBlank}${BACKLINKS_MARKER}\n${HISTORY_HEADING}\n\n${bullet}\n`;
62
+ }
63
+
64
+ // Walk the appendix section looking for `- [...](...)` bullets and
65
+ // collect the session id from each link href. No regex — the href
66
+ // format is tightly constrained (one bracket pair then one paren
67
+ // pair) so a character scan is sufficient and faster than a
68
+ // backtracking regex.
69
+ function extractSessionIdsFromAppendix(appendix: string): Set<string> {
70
+ const ids = new Set<string>();
71
+ for (const rawLine of appendix.split("\n")) {
72
+ const line = rawLine.trim();
73
+ if (!line.startsWith("- ") && !line.startsWith("* ")) continue;
74
+ const href = extractHrefFromBullet(line);
75
+ if (!href) continue;
76
+ const id = extractSessionIdFromHref(href);
77
+ if (id) ids.add(id);
78
+ }
79
+ return ids;
80
+ }
81
+
82
+ // Given a bullet line like `- [session abc](../../chat/abc-123.jsonl)`,
83
+ // return the href. Returns null for malformed bullets.
84
+ function extractHrefFromBullet(line: string): string | null {
85
+ const bracketOpen = line.indexOf("[");
86
+ if (bracketOpen === -1) return null;
87
+ const bracketClose = line.indexOf("]", bracketOpen + 1);
88
+ if (bracketClose === -1) return null;
89
+ if (line[bracketClose + 1] !== "(") return null;
90
+ const parenClose = line.indexOf(")", bracketClose + 2);
91
+ if (parenClose === -1) return null;
92
+ return line.slice(bracketClose + 2, parenClose);
93
+ }
94
+
95
+ // Pull the session id out of an href that ends with a `.jsonl`
96
+ // filename under some `chat/` segment. Supports both workspace-
97
+ // absolute (`/chat/<id>.jsonl`) and relative (`../../chat/<id>.jsonl`)
98
+ // forms so the dedupe is robust to the caller's path choice.
99
+ function extractSessionIdFromHref(href: string): string | null {
100
+ const JSONL_SUFFIX = ".jsonl";
101
+ // Strip optional #fragment or ?query tail.
102
+ const cleanHref = stripFragmentAndQuery(href);
103
+ if (!cleanHref.endsWith(JSONL_SUFFIX)) return null;
104
+ const lastSlash = cleanHref.lastIndexOf("/");
105
+ if (lastSlash === -1) return null;
106
+ const parentSegment = findPrecedingSegment(cleanHref, lastSlash);
107
+ if (parentSegment !== "chat") return null;
108
+ const id = cleanHref.slice(lastSlash + 1, cleanHref.length - JSONL_SUFFIX.length);
109
+ if (id.length === 0 || id.includes("/")) return null;
110
+ return id;
111
+ }
112
+
113
+ function stripFragmentAndQuery(s: string): string {
114
+ let end = s.length;
115
+ const hash = s.indexOf("#");
116
+ if (hash !== -1) end = hash;
117
+ const query = s.indexOf("?");
118
+ if (query !== -1 && query < end) end = query;
119
+ return s.slice(0, end);
120
+ }
121
+
122
+ // Given `.../chat/abc.jsonl` and the index of the last `/`, return
123
+ // the segment immediately before the filename (here: `"chat"`).
124
+ function findPrecedingSegment(s: string, lastSlash: number): string {
125
+ const prevSlash = s.lastIndexOf("/", lastSlash - 1);
126
+ return s.slice(prevSlash + 1, lastSlash);
127
+ }
128
+
129
+ // Insert `newBullet` at the end of the History list inside the
130
+ // appendix. The appendix already contains the marker + heading +
131
+ // zero or more bullets; we simply append another bullet line,
132
+ // preserving any trailing blank lines the user may have added.
133
+ function appendBulletToAppendix(appendix: string, newBullet: string): string {
134
+ // Normalise: ensure exactly one trailing newline before adding.
135
+ // Hand-rolled rtrim so sonarjs/slow-regex stays quiet.
136
+ let end = appendix.length;
137
+ while (end > 0) {
138
+ const ch = appendix.charCodeAt(end - 1);
139
+ // Whitespace codepoints we expect here: \n, \r, \t, space.
140
+ if (ch !== 10 && ch !== 13 && ch !== 9 && ch !== 32) break;
141
+ end--;
142
+ }
143
+ return `${appendix.slice(0, end)}\n${newBullet}\n`;
144
+ }
@@ -0,0 +1,66 @@
1
+ import { execSync } from "child_process";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { log } from "../system/logger/index.js";
6
+ import { EAGER_WORKSPACE_DIRS, WORKSPACE_FILES, WORKSPACE_PATHS, workspacePath } from "./paths.js";
7
+ import { existsInWorkspace, writeWorkspaceTextSync } from "../utils/files/workspace-io.js";
8
+ import { loadCustomDirs, ensureCustomDirs } from "./custom-dirs.js";
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const TEMPLATES_DIR = path.join(__dirname, "helps");
12
+
13
+ // Re-exported so existing callers (`import { workspacePath } from
14
+ // "./workspace.js"`) keep working. See workspace-paths.ts for the
15
+ // definitive source.
16
+ export { workspacePath };
17
+
18
+ // Must exist before downstream modules call realpathSync(workspacePath) at their own module-load time.
19
+ fs.mkdirSync(workspacePath, { recursive: true });
20
+
21
+ export function initWorkspace(): string {
22
+ // Create directory structure if needed
23
+ for (const key of EAGER_WORKSPACE_DIRS) {
24
+ fs.mkdirSync(WORKSPACE_PATHS[key], { recursive: true });
25
+ }
26
+
27
+ // Create memory.md if it doesn't exist
28
+ if (!existsInWorkspace(WORKSPACE_FILES.memory)) {
29
+ writeWorkspaceTextSync(WORKSPACE_FILES.memory, "# Memory\n\nDistilled facts about you and your work.\n");
30
+ }
31
+
32
+ // Always sync all files from server/helps/ into workspace/helps/
33
+ fs.mkdirSync(WORKSPACE_PATHS.helps, { recursive: true });
34
+ for (const file of fs.readdirSync(TEMPLATES_DIR)) {
35
+ fs.copyFileSync(path.join(TEMPLATES_DIR, file), path.join(WORKSPACE_PATHS.helps, file));
36
+ }
37
+
38
+ // Create .gitignore if missing. The workspace is a git repo for
39
+ // version-tracking user data, but cloned dev repos under github/
40
+ // have their own .git and shouldn't be committed (#256).
41
+ if (!existsInWorkspace(".gitignore")) {
42
+ writeWorkspaceTextSync(
43
+ ".gitignore",
44
+ ["# Cloned repositories have their own .git — don't nest", "github/", "", "# Auth token (regenerated each startup)", ".session-token", ""].join("\n"),
45
+ );
46
+ }
47
+
48
+ // User-defined custom directories (#239)
49
+ const customDirs = loadCustomDirs();
50
+ if (customDirs.length > 0) {
51
+ ensureCustomDirs(customDirs);
52
+ log.info("workspace", "custom directories loaded", {
53
+ count: customDirs.length,
54
+ });
55
+ }
56
+
57
+ // Git init if not already a repo
58
+ const gitDir = path.join(workspacePath, ".git");
59
+ if (!fs.existsSync(gitDir)) {
60
+ execSync("git init", { cwd: workspacePath });
61
+ log.info("workspace", "initialized git repository", { workspacePath });
62
+ }
63
+
64
+ log.info("workspace", "ready", { workspacePath });
65
+ return workspacePath;
66
+ }