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,743 @@
1
+ // The daily pass: walk chat/*.jsonl, find sessions changed since
2
+ // the last run, bucket events by local-date, call the archivist
3
+ // once per affected day, and apply its output (write daily/*.md,
4
+ // create/append/rewrite topics/*.md).
5
+ //
6
+ // This file is the only one in the journal module that combines
7
+ // filesystem side-effects with LLM calls. Pure bits (event parsing,
8
+ // bucketing) are factored into small exported helpers so tests can
9
+ // exercise them without touching disk.
10
+
11
+ import fsp from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { workspacePath as defaultWorkspacePath } from "../workspace.js";
14
+ import { WORKSPACE_DIRS } from "../paths.js";
15
+ import { writeDailySummary, readDailySummary, readTopicFile, writeTopicFile, appendOrCreateTopic, readAllTopicFiles } from "../../utils/files/journal-io.js";
16
+ import { readSessionMeta as readSessionMetaIO, readSessionJsonl as readSessionJsonlIO } from "../../utils/files/session-io.js";
17
+ import { statUnder } from "../../utils/files/workspace-io.js";
18
+ import {
19
+ type Summarize,
20
+ type SessionExcerpt,
21
+ type SessionEventExcerpt,
22
+ type ExistingTopicSnapshot,
23
+ type DailyArchivistInput,
24
+ type DailyArchivistOutput,
25
+ type TopicUpdate,
26
+ DAILY_SYSTEM_PROMPT,
27
+ buildDailyUserPrompt,
28
+ extractJsonObject,
29
+ isDailyArchivistOutput,
30
+ ClaudeCliNotFoundError,
31
+ } from "./archivist.js";
32
+ import { toIsoDate, slugify } from "./paths.js";
33
+ import { findDirtySessions, applyProcessed, type SessionFileMeta } from "./diff.js";
34
+ import { rewriteWorkspaceLinks } from "./linkRewrite.js";
35
+ import { writeState, type JournalState } from "./state.js";
36
+ import { log } from "../../system/logger/index.js";
37
+ import { EVENT_TYPES } from "../../../src/types/events.js";
38
+ import { extractAndAppendMemory } from "./memoryExtractor.js";
39
+ import { isRecord } from "../../utils/types.js";
40
+
41
+ // --- Constants ------------------------------------------------------
42
+
43
+ // Per-event content is truncated before handing to the archivist so
44
+ // an accidentally huge tool result (e.g. base64 image data) doesn't
45
+ // blow past the CLI's context window.
46
+ const MAX_EVENT_CONTENT_CHARS = 600;
47
+
48
+ // Hard cap on events per session included in the prompt. Sessions
49
+ // with thousands of events get their head kept — the archivist can
50
+ // generally get the gist from the opening.
51
+ const MAX_EVENTS_PER_SESSION = 80;
52
+
53
+ // --- Public entry ---------------------------------------------------
54
+
55
+ export interface DailyPassDeps {
56
+ workspaceRoot?: string;
57
+ summarize: Summarize;
58
+ // Active session ids to skip (mid-write). Caller passes the
59
+ // live session registry to avoid ingesting jsonl files that the
60
+ // agent is still appending to.
61
+ activeSessionIds: ReadonlySet<string>;
62
+ }
63
+
64
+ export interface DailyPassResult {
65
+ daysTouched: string[]; // YYYY-MM-DD values actually written
66
+ sessionsIngested: string[];
67
+ topicsCreated: string[];
68
+ topicsUpdated: string[];
69
+ skipped: Array<{ date: string; reason: string }>;
70
+ }
71
+
72
+ export async function runDailyPass(state: JournalState, deps: DailyPassDeps): Promise<{ nextState: JournalState; result: DailyPassResult }> {
73
+ const workspaceRoot = deps.workspaceRoot ?? defaultWorkspacePath;
74
+ const chatDir = path.join(workspaceRoot, WORKSPACE_DIRS.chat);
75
+ const result: DailyPassResult = {
76
+ daysTouched: [],
77
+ sessionsIngested: [],
78
+ topicsCreated: [],
79
+ topicsUpdated: [],
80
+ skipped: [],
81
+ };
82
+
83
+ // --- Phase 1: figure out what work there is to do ------------------
84
+ const eligible = (await listSessionMetas(chatDir)).filter((m) => !deps.activeSessionIds.has(m.id));
85
+ const { dirty } = findDirtySessions(eligible, state.processedSessions);
86
+ if (dirty.length === 0) return { nextState: { ...state }, result };
87
+
88
+ const perSessionExcerpts = await loadDirtySessionExcerpts(chatDir, dirty, workspaceRoot);
89
+ const { dayBuckets, sessionToDays } = buildDayBuckets(perSessionExcerpts);
90
+
91
+ // Note: we intentionally do NOT early-return when `dayBuckets` is
92
+ // empty. Letting the pipeline fall through preserves the pre-
93
+ // refactor behaviour for the edge case where every dirty session
94
+ // produces zero excerpts (all malformed, or all metadata/tool-only
95
+ // with no text turns): `readAllTopics` still fires, and the
96
+ // returned `nextState.knownTopics` is still normalized / sorted
97
+ // from the existing state. The empty `orderedDays` loop then
98
+ // iterates zero times and we fall through to `return { nextState,
99
+ // result }`.
100
+
101
+ // --- Phase 2: set up per-pass state --------------------------------
102
+ const existingTopics = await readAllTopics(workspaceRoot);
103
+ const newTopicsSeen = new Set<string>(state.knownTopics);
104
+ // `nextState` is rebuilt through the day loop and persisted after
105
+ // each successful day via writeState (atomic tmp+rename). We do
106
+ // NOT bump lastDailyRunAt here — that's the outer runner's job
107
+ // after the whole pass (including optimization) finishes, so
108
+ // partial progress doesn't look like a complete pass.
109
+ let nextState: JournalState = {
110
+ ...state,
111
+ knownTopics: [...newTopicsSeen].sort(),
112
+ };
113
+ const dirtyMetaById = new Map(eligible.map((m) => [m.id, m]));
114
+ // Process days in chronological order so topic state accumulates
115
+ // naturally: an earlier day's update is visible to the next day.
116
+ const orderedDays = [...dayBuckets.keys()].sort();
117
+
118
+ // --- Phase 3: process each day -------------------------------------
119
+ for (const date of orderedDays) {
120
+ const dayResult = await processDayAndAdvance({
121
+ workspaceRoot,
122
+ date,
123
+ dayBuckets,
124
+ existingTopics,
125
+ summarize: deps.summarize,
126
+ sessionToDays,
127
+ dirtyMetaById,
128
+ newTopicsSeen,
129
+ nextState,
130
+ });
131
+ if (dayResult.kind === "skipped") {
132
+ result.skipped.push({ date, reason: dayResult.reason });
133
+ } else {
134
+ result.daysTouched.push(date);
135
+ result.topicsCreated.push(...dayResult.topicsCreated);
136
+ result.topicsUpdated.push(...dayResult.topicsUpdated);
137
+ result.sessionsIngested.push(...dayResult.sessionsIngested);
138
+ }
139
+ nextState = dayResult.nextState;
140
+ }
141
+
142
+ // --- Phase 4: memory extraction ------------------------------------
143
+ await maybeExtractMemory(perSessionExcerpts, workspaceRoot, deps);
144
+
145
+ return { nextState, result };
146
+ }
147
+
148
+ // --- Phase 3 helper: single-day processing + state advance -----------
149
+ // Extracted from the Phase 3 for-loop to keep runDailyPass under
150
+ // the sonarjs/cognitive-complexity threshold.
151
+
152
+ interface ProcessDayInput {
153
+ workspaceRoot: string;
154
+ date: string;
155
+ dayBuckets: ReadonlyMap<string, SessionExcerpt[]>;
156
+ existingTopics: ExistingTopicSnapshot[];
157
+ summarize: Summarize;
158
+ sessionToDays: Map<string, Set<string>>;
159
+ dirtyMetaById: ReadonlyMap<string, SessionFileMeta>;
160
+ newTopicsSeen: Set<string>;
161
+ nextState: JournalState;
162
+ }
163
+
164
+ type ProcessDayOutput =
165
+ | {
166
+ kind: "skipped";
167
+ reason: string;
168
+ nextState: JournalState;
169
+ }
170
+ | {
171
+ kind: "processed";
172
+ topicsCreated: string[];
173
+ topicsUpdated: string[];
174
+ sessionsIngested: string[];
175
+ nextState: JournalState;
176
+ };
177
+
178
+ async function processDayAndAdvance(input: ProcessDayInput): Promise<ProcessDayOutput> {
179
+ const excerpts = input.dayBuckets.get(input.date) ?? [];
180
+ const dayOutcome = await processOneDay(input.workspaceRoot, input.date, excerpts, input.existingTopics, input.summarize);
181
+ if (dayOutcome.kind === "skipped") {
182
+ return {
183
+ kind: "skipped",
184
+ reason: dayOutcome.reason,
185
+ nextState: input.nextState,
186
+ };
187
+ }
188
+
189
+ for (const slug of dayOutcome.topicsTouched) {
190
+ input.newTopicsSeen.add(slug);
191
+ }
192
+
193
+ const justCompleted = computeJustCompletedSessions(input.date, excerpts, input.sessionToDays, input.dirtyMetaById);
194
+ const sessionsIngested = justCompleted.map((m) => m.id);
195
+ const nextState = advanceJournalState(input.nextState, justCompleted, input.newTopicsSeen);
196
+ await persistStateAfterDay(input.workspaceRoot, nextState, input.date);
197
+
198
+ return {
199
+ kind: "processed",
200
+ topicsCreated: dayOutcome.topicsCreated,
201
+ topicsUpdated: dayOutcome.topicsUpdated,
202
+ sessionsIngested,
203
+ nextState,
204
+ };
205
+ }
206
+
207
+ // --- Phase 4 helper: memory extraction -------------------------------
208
+ // Scan dirty-session excerpts for durable user facts and append new
209
+ // ones to memory.md. Fire-and-forget: if extraction fails the daily
210
+ // summaries are already written, so the pass is still useful.
211
+
212
+ async function maybeExtractMemory(
213
+ perSessionExcerpts: ReadonlyMap<string, ReadonlyMap<string, SessionExcerpt>>,
214
+ workspaceRoot: string,
215
+ deps: DailyPassDeps,
216
+ ): Promise<void> {
217
+ if (perSessionExcerpts.size === 0) return;
218
+ const excerptLines: string[] = [];
219
+ for (const [, byDate] of perSessionExcerpts) {
220
+ for (const [, excerpt] of byDate) {
221
+ const userLines = excerpt.events.filter((e: SessionEventExcerpt) => e.source === "user").map((e: SessionEventExcerpt) => `[user] ${e.content}`);
222
+ if (userLines.length > 0) excerptLines.push(userLines.join("\n"));
223
+ }
224
+ }
225
+ try {
226
+ await extractAndAppendMemory({
227
+ workspaceRoot,
228
+ excerpts: excerptLines.join("\n---\n"),
229
+ summarize: deps.summarize,
230
+ });
231
+ } catch (err) {
232
+ log.warn("daily-pass", "memory extraction failed (non-fatal)", {
233
+ error: String(err),
234
+ });
235
+ }
236
+ }
237
+
238
+ // --- Phase 3 helper: per-day side-effecting pipeline ----------------
239
+
240
+ // Discriminated return so `runDailyPass` can branch on outcome
241
+ // without digging into null or throwing.
242
+ export type DayOutcome =
243
+ | { kind: "skipped"; reason: string }
244
+ | {
245
+ kind: "processed";
246
+ topicsCreated: string[];
247
+ topicsUpdated: string[];
248
+ // Union of created + updated — handed back so the caller
249
+ // can keep `newTopicsSeen` in sync without recomputing.
250
+ topicsTouched: string[];
251
+ };
252
+
253
+ // Run the archivist for one day and apply its output (daily
254
+ // summary + topic updates). All filesystem writes land here so
255
+ // `runDailyPass` stays branching-lean.
256
+ async function processOneDay(
257
+ workspaceRoot: string,
258
+ date: string,
259
+ excerpts: SessionExcerpt[],
260
+ existingTopics: ExistingTopicSnapshot[],
261
+ summarize: Summarize,
262
+ ): Promise<DayOutcome> {
263
+ const existingDaily = await readDailySummary(date, workspaceRoot);
264
+ const input: DailyArchivistInput = {
265
+ date,
266
+ existingDailySummary: existingDaily,
267
+ existingTopicSummaries: existingTopics,
268
+ sessionExcerpts: excerpts,
269
+ };
270
+
271
+ const rawOutput = await callSummarizeForDay(date, input, summarize);
272
+ if (rawOutput === null) {
273
+ return { kind: "skipped", reason: "summarize failed" };
274
+ }
275
+
276
+ const parsed = parseArchivistOutput(rawOutput);
277
+ if (parsed === null) {
278
+ log.warn("journal", "archivist returned unusable JSON, skipping", {
279
+ date,
280
+ });
281
+ return { kind: "skipped", reason: "unusable archivist JSON" };
282
+ }
283
+
284
+ await writeDailySummaryForDate(workspaceRoot, date, parsed.dailySummaryMarkdown);
285
+
286
+ const topicOutcome = await processTopicUpdatesForDay(workspaceRoot, parsed.topicUpdates, existingTopics);
287
+
288
+ return {
289
+ kind: "processed",
290
+ topicsCreated: topicOutcome.created,
291
+ topicsUpdated: topicOutcome.updated,
292
+ topicsTouched: [...topicOutcome.created, ...topicOutcome.updated],
293
+ };
294
+ }
295
+
296
+ // Call the archivist summarizer and narrow its failure modes.
297
+ // Returns null on recoverable failures (logged + skipped), throws
298
+ // only for `ClaudeCliNotFoundError` which the outer runner uses to
299
+ // disable the whole journal feature for the process lifetime.
300
+ async function callSummarizeForDay(date: string, input: DailyArchivistInput, summarize: Summarize): Promise<string | null> {
301
+ try {
302
+ return await summarize(DAILY_SYSTEM_PROMPT, buildDailyUserPrompt(input));
303
+ } catch (err) {
304
+ if (err instanceof ClaudeCliNotFoundError) throw err;
305
+ log.warn("journal", "summarize failed, skipping day", {
306
+ date,
307
+ error: String(err),
308
+ });
309
+ return null;
310
+ }
311
+ }
312
+
313
+ // Side-effecting wrapper: rewrite workspace-absolute links in the
314
+ // archivist output relative to the daily summary's own location,
315
+ // then write the file to disk. Factored out so the main loop's
316
+ // body no longer contains path-math and I/O intermixed.
317
+ async function writeDailySummaryForDate(workspaceRoot: string, date: string, rawMarkdown: string): Promise<void> {
318
+ // Rewrite any /workspace-absolute links in the archivist's output
319
+ // into true-relative links from the daily summary's location
320
+ // before writing to disk. Same treatment below for topic files.
321
+ const [yearPart, monthPart, dayPart] = date.split("-");
322
+ const dailyFileWsPath = path.posix.join(WORKSPACE_DIRS.summaries, "daily", yearPart, monthPart, `${dayPart}.md`);
323
+ const content = rewriteWorkspaceLinks(dailyFileWsPath, rawMarkdown);
324
+ await writeDailySummary(date, content, workspaceRoot);
325
+ }
326
+
327
+ // Apply every topic update the archivist asked for, keeping the
328
+ // in-memory `existingTopics` snapshot in sync so the next day in
329
+ // this same pass sees fresh content. Mutates `existingTopics`.
330
+ //
331
+ // Per-update failures (EACCES, EIO, etc. surfaced by appendOrCreate)
332
+ // are logged and skipped so a single broken topic file doesn't kill
333
+ // the whole pass after days of progress have already been committed.
334
+ async function processTopicUpdatesForDay(
335
+ workspaceRoot: string,
336
+ updates: readonly TopicUpdate[],
337
+ existingTopics: ExistingTopicSnapshot[],
338
+ ): Promise<{ created: string[]; updated: string[] }> {
339
+ const created: string[] = [];
340
+ const updated: string[] = [];
341
+ for (const update of updates) {
342
+ const normalized = normalizeTopicAction(update, existingTopics);
343
+ try {
344
+ const outcome = await applyTopicUpdate(workspaceRoot, normalized);
345
+ if (outcome === "created") created.push(normalized.slug);
346
+ else if (outcome === "updated") updated.push(normalized.slug);
347
+ await refreshTopicSnapshot(workspaceRoot, normalized.slug, existingTopics);
348
+ } catch (err) {
349
+ log.warn("journal", "failed to apply topic update", {
350
+ slug: normalized.slug,
351
+ error: String(err),
352
+ });
353
+ }
354
+ }
355
+ return { created, updated };
356
+ }
357
+
358
+ // Re-read the topic file fresh and upsert its snapshot into the
359
+ // in-memory `existingTopics` list so the next day's archivist
360
+ // call sees the latest content.
361
+ async function refreshTopicSnapshot(workspaceRoot: string, slug: string, existingTopics: ExistingTopicSnapshot[]): Promise<void> {
362
+ const newBody = await readTopicFile(slug, workspaceRoot);
363
+ if (newBody === null) return;
364
+ const snapshot: ExistingTopicSnapshot = { slug, content: newBody };
365
+ const idx = existingTopics.findIndex((t) => t.slug === slug);
366
+ if (idx === -1) existingTopics.push(snapshot);
367
+ else existingTopics[idx] = snapshot;
368
+ }
369
+
370
+ // Persist the in-progress journal state after each day so a
371
+ // mid-pass crash only costs the work written after the last
372
+ // checkpoint. Write failures are logged but don't fail the pass —
373
+ // the day's markdown is already on disk and the next run will
374
+ // catch up.
375
+ async function persistStateAfterDay(workspaceRoot: string, state: JournalState, date: string): Promise<void> {
376
+ try {
377
+ await writeState(workspaceRoot, state);
378
+ } catch (err) {
379
+ log.warn("journal", "failed to persist state after day", {
380
+ date,
381
+ error: String(err),
382
+ });
383
+ }
384
+ }
385
+
386
+ // --- Pure helpers (exported for unit tests) ------------------------
387
+
388
+ // Bucket every session's per-date excerpts into a `dayBuckets`
389
+ // map and a `sessionToDays` tracking map in one pass. Inputs are
390
+ // the per-session excerpts loaded by `loadDirtySessionExcerpts`;
391
+ // outputs are plain Maps the day loop can consume directly.
392
+ //
393
+ // `sessionToDays` is used later by `computeJustCompletedSessions`
394
+ // to mark a session fully processed only after its last day has
395
+ // been written. That's why both Maps are built here together —
396
+ // they're two views of the same input and staying in sync matters.
397
+ export interface DayBucketsPlan {
398
+ dayBuckets: Map<string, SessionExcerpt[]>;
399
+ sessionToDays: Map<string, Set<string>>;
400
+ }
401
+
402
+ export function buildDayBuckets(perSessionExcerpts: ReadonlyMap<string, ReadonlyMap<string, SessionExcerpt>>): DayBucketsPlan {
403
+ const dayBuckets = new Map<string, SessionExcerpt[]>();
404
+ const sessionToDays = new Map<string, Set<string>>();
405
+ for (const [sessionId, byDate] of perSessionExcerpts) {
406
+ for (const [date, excerpt] of byDate) {
407
+ const bucket = dayBuckets.get(date);
408
+ if (bucket) bucket.push(excerpt);
409
+ else dayBuckets.set(date, [excerpt]);
410
+
411
+ let days = sessionToDays.get(sessionId);
412
+ if (!days) {
413
+ days = new Set<string>();
414
+ sessionToDays.set(sessionId, days);
415
+ }
416
+ days.add(date);
417
+ }
418
+ }
419
+ return { dayBuckets, sessionToDays };
420
+ }
421
+
422
+ // Apply the append-to-missing → create guard and canonicalise
423
+ // the slug. The archivist occasionally asks to "append" to a
424
+ // brand-new topic; silently promoting that to "create" removes a
425
+ // whole class of LLM mistakes without needing a schema rejection.
426
+ // Also rewrites any workspace-absolute links in the body relative
427
+ // to the target topic file's location.
428
+ export function normalizeTopicAction(update: TopicUpdate, existingTopics: readonly ExistingTopicSnapshot[]): TopicUpdate {
429
+ const canonicalSlug = slugify(update.slug);
430
+ const exists = existingTopics.some((t) => t.slug === canonicalSlug);
431
+ const topicFileWsPath = path.posix.join(WORKSPACE_DIRS.summaries, "topics", `${canonicalSlug}.md`);
432
+ return {
433
+ slug: canonicalSlug,
434
+ action: !exists && update.action === "append" ? "create" : update.action,
435
+ content: rewriteWorkspaceLinks(topicFileWsPath, update.content),
436
+ };
437
+ }
438
+
439
+ // Parse an archivist raw output string into a validated
440
+ // DailyArchivistOutput. Returns null when the JSON envelope is
441
+ // missing or the shape doesn't match, so callers can treat it
442
+ // as a skip reason without needing a separate `isValid` check.
443
+ // Pure — combines `extractJsonObject` + `isDailyArchivistOutput`
444
+ // behind a single gate.
445
+ export function parseArchivistOutput(rawOutput: string): DailyArchivistOutput | null {
446
+ const parsed = extractJsonObject(rawOutput);
447
+ if (!isDailyArchivistOutput(parsed)) return null;
448
+ return parsed;
449
+ }
450
+
451
+ // Decide which sessions have just completed their last pending
452
+ // day, mutating `sessionToDays` to drop `date` from every entry
453
+ // that touches it. Returns the `SessionFileMeta` records for the
454
+ // freshly-completed sessions so the caller can feed them into
455
+ // `applyProcessed`.
456
+ //
457
+ // A session is "complete" when its pending-days set, *after*
458
+ // removing the current date, is empty. Sessions not in
459
+ // `sessionToDays` (or not in `dirtyMetaById`) are silently
460
+ // skipped — defensive against unexpected inputs, and the same
461
+ // shape as the pre-refactor inline code.
462
+ export function computeJustCompletedSessions(
463
+ date: string,
464
+ excerpts: readonly SessionExcerpt[],
465
+ sessionToDays: Map<string, Set<string>>,
466
+ dirtyMetaById: ReadonlyMap<string, SessionFileMeta>,
467
+ ): SessionFileMeta[] {
468
+ const justCompleted: SessionFileMeta[] = [];
469
+ for (const excerpt of excerpts) {
470
+ const pending = sessionToDays.get(excerpt.sessionId);
471
+ if (!pending) continue;
472
+ pending.delete(date);
473
+ if (pending.size === 0) {
474
+ sessionToDays.delete(excerpt.sessionId);
475
+ const meta = dirtyMetaById.get(excerpt.sessionId);
476
+ if (meta) justCompleted.push(meta);
477
+ }
478
+ }
479
+ return justCompleted;
480
+ }
481
+
482
+ // Build the next JournalState from the previous one plus a batch
483
+ // of just-completed sessions and the current view of known
484
+ // topics. Tiny pure wrapper so the day loop has one place to
485
+ // advance state instead of five-line spread literals scattered
486
+ // through it.
487
+ export function advanceJournalState(prev: JournalState, justCompleted: readonly SessionFileMeta[], newTopicsSeen: ReadonlySet<string>): JournalState {
488
+ return {
489
+ ...prev,
490
+ processedSessions: applyProcessed(prev.processedSessions, [...justCompleted]),
491
+ knownTopics: [...newTopicsSeen].sort(),
492
+ };
493
+ }
494
+
495
+ // --- Filesystem helpers ---------------------------------------------
496
+
497
+ // Load every dirty session's jsonl, bucket events by local-date,
498
+ // and return the whole collection as a Map<sessionId, Map<date,
499
+ // excerpt>>. Malformed sessions are logged and skipped so one
500
+ // bad jsonl can't crash the pass. Returned shape is exactly what
501
+ // `buildDayBuckets` wants as input.
502
+ async function loadDirtySessionExcerpts(chatDir: string, dirty: readonly string[], workspaceRoot: string): Promise<Map<string, Map<string, SessionExcerpt>>> {
503
+ const perSession = new Map<string, Map<string, SessionExcerpt>>();
504
+ for (const sessionId of dirty) {
505
+ try {
506
+ const excerpts = await loadSessionExcerptsByDate(chatDir, sessionId, workspaceRoot);
507
+ if (excerpts.size > 0) perSession.set(sessionId, excerpts);
508
+ } catch (err) {
509
+ log.warn("journal", "failed to load session", {
510
+ sessionId,
511
+ error: String(err),
512
+ });
513
+ }
514
+ }
515
+ return perSession;
516
+ }
517
+
518
+ async function listSessionMetas(chatDir: string): Promise<SessionFileMeta[]> {
519
+ let entries: string[];
520
+ try {
521
+ entries = await fsp.readdir(chatDir);
522
+ } catch {
523
+ return [];
524
+ }
525
+ const out: SessionFileMeta[] = [];
526
+ for (const name of entries) {
527
+ if (!name.endsWith(".jsonl")) continue;
528
+ const full = path.join(chatDir, name);
529
+ try {
530
+ const st = await fsp.stat(full);
531
+ out.push({
532
+ id: name.replace(/\.jsonl$/, ""),
533
+ mtimeMs: st.mtimeMs,
534
+ });
535
+ } catch {
536
+ // file vanished between readdir and stat — ignore
537
+ }
538
+ }
539
+ return out;
540
+ }
541
+
542
+ async function loadSessionExcerptsByDate(chatDir: string, sessionId: string, workspaceRoot: string): Promise<Map<string, SessionExcerpt>> {
543
+ const roleId = await readRoleIdFromMeta(sessionId, workspaceRoot);
544
+ const raw = await readSessionJsonlIO(sessionId, workspaceRoot);
545
+ if (!raw) return new Map();
546
+
547
+ const stat = await statUnder(workspaceRoot, path.posix.join(WORKSPACE_DIRS.chat, `${sessionId}.jsonl`));
548
+ const fallbackDate = toIsoDate(stat?.mtimeMs ?? Date.now());
549
+
550
+ const parsedEvents = parseJsonlEvents(raw, MAX_EVENTS_PER_SESSION);
551
+ return bucketParsedEvents(parsedEvents, sessionId, roleId, fallbackDate);
552
+ }
553
+
554
+ // Walk a jsonl string and return at most `maxEvents` parsed events
555
+ // ready for bucketing. Skips blank lines, malformed JSON,
556
+ // metadata entries, and anything `parseEntry` rejects. Pure —
557
+ // exported so tests can exercise it with fabricated jsonl strings.
558
+ export function parseJsonlEvents(raw: string, maxEvents: number): ParsedEntry[] {
559
+ const events: ParsedEntry[] = [];
560
+ for (const line of raw.split("\n")) {
561
+ if (events.length >= maxEvents) break;
562
+ const entry = parseJsonlLine(line);
563
+ if (entry === null) continue;
564
+ if (isMetadataEntry(entry)) continue;
565
+ const parsed = parseEntry(entry);
566
+ if (parsed) events.push(parsed);
567
+ }
568
+ return events;
569
+ }
570
+
571
+ // JSON.parse one jsonl line, guarding against blank lines,
572
+ // malformed JSON, and any JSON value that isn't a plain object.
573
+ // `JSON.parse` will happily return `null`, arrays, strings,
574
+ // numbers, or booleans, none of which the downstream
575
+ // `parseEntry` / `entryToExcerpt` functions can consume — and
576
+ // `entry.type` on a `null` or primitive throws at runtime.
577
+ // Returning `null` here collapses every invalid shape into the
578
+ // same "skip this line" sentinel the caller already handles.
579
+ function parseJsonlLine(line: string): Record<string, unknown> | null {
580
+ if (!line.trim()) return null;
581
+ let parsed: unknown;
582
+ try {
583
+ parsed = JSON.parse(line);
584
+ } catch {
585
+ return null;
586
+ }
587
+ if (!isRecord(parsed)) {
588
+ return null;
589
+ }
590
+ return parsed as Record<string, unknown>;
591
+ }
592
+
593
+ function isMetadataEntry(entry: Record<string, unknown>): boolean {
594
+ return entry.type === EVENT_TYPES.sessionMeta || entry.type === EVENT_TYPES.claudeSessionId;
595
+ }
596
+
597
+ // Collect parsed events into per-date buckets using `fallbackDate`
598
+ // for every event, since the legacy jsonl format has no per-event
599
+ // timestamps. Extracted so the I/O-free bucket-building can be
600
+ // reasoned about and unit-tested without a real jsonl file.
601
+ export function bucketParsedEvents(events: readonly ParsedEntry[], sessionId: string, roleId: string, fallbackDate: string): Map<string, SessionExcerpt> {
602
+ const buckets = new Map<string, SessionExcerpt>();
603
+ for (const parsed of events) {
604
+ let bucket = buckets.get(fallbackDate);
605
+ if (!bucket) {
606
+ bucket = { sessionId, roleId, events: [], artifactPaths: [] };
607
+ buckets.set(fallbackDate, bucket);
608
+ }
609
+ bucket.events.push(parsed.excerpt);
610
+ for (const p of parsed.artifactPaths) {
611
+ if (!bucket.artifactPaths.includes(p)) bucket.artifactPaths.push(p);
612
+ }
613
+ }
614
+ return buckets;
615
+ }
616
+
617
+ async function readRoleIdFromMeta(sessionId: string, workspaceRoot: string): Promise<string> {
618
+ try {
619
+ const meta = await readSessionMetaIO(sessionId, workspaceRoot);
620
+ if (meta && typeof meta.roleId === "string") return meta.roleId;
621
+ } catch {
622
+ // ignore
623
+ }
624
+ return "unknown";
625
+ }
626
+
627
+ // Convert one jsonl entry into a flat excerpt the archivist can read,
628
+ // plus any workspace-relative artifact paths the entry references.
629
+ // Exported so tests can exercise it with fabricated entries.
630
+ export interface ParsedEntry {
631
+ excerpt: SessionEventExcerpt;
632
+ // 0+ workspace-relative artifact paths referenced by this entry.
633
+ // Used to build the ARTIFACTS REFERENCED prompt section.
634
+ artifactPaths: string[];
635
+ }
636
+
637
+ export function parseEntry(entry: Record<string, unknown>): ParsedEntry | null {
638
+ const excerpt = entryToExcerpt(entry);
639
+ if (!excerpt) return null;
640
+ return {
641
+ excerpt,
642
+ artifactPaths: extractArtifactPaths(entry),
643
+ };
644
+ }
645
+
646
+ // Legacy single-purpose form used by the existing unit tests.
647
+ // Prefer `parseEntry` for code that also wants artifact paths.
648
+ export function entryToExcerpt(entry: Record<string, unknown>): SessionEventExcerpt | null {
649
+ const source = typeof entry.source === "string" ? entry.source : "unknown";
650
+ const type = typeof entry.type === "string" ? entry.type : "unknown";
651
+
652
+ // text entries: {source, type: "text", message}
653
+ if (type === EVENT_TYPES.text && typeof entry.message === "string") {
654
+ return {
655
+ source,
656
+ type,
657
+ content: truncate(entry.message, MAX_EVENT_CONTENT_CHARS),
658
+ };
659
+ }
660
+ // tool_result entries: {source: "tool", type: "tool_result", result: {toolName, message, ...}}
661
+ // `typeof null === "object"` so we must explicitly reject null
662
+ // to avoid a NullPointerException-style crash when accessing
663
+ // r.toolName below.
664
+ if (type === EVENT_TYPES.toolResult && isRecord(entry.result)) {
665
+ const r = entry.result as Record<string, unknown>;
666
+ const toolName = typeof r.toolName === "string" ? r.toolName : "tool";
667
+ const label = (typeof r.title === "string" && r.title) || (typeof r.message === "string" && r.message) || "(no message)";
668
+ return {
669
+ source,
670
+ type,
671
+ content: `${toolName}: ${truncate(String(label), MAX_EVENT_CONTENT_CHARS - toolName.length - 2)}`,
672
+ };
673
+ }
674
+ return null;
675
+ }
676
+
677
+ // Pull workspace-relative artifact paths out of a jsonl entry. The
678
+ // extraction is tool-aware: different plugins stash file paths in
679
+ // different places inside their tool_result data. Exported for
680
+ // tests.
681
+ export function extractArtifactPaths(entry: Record<string, unknown>): string[] {
682
+ if (entry.type !== "tool_result") return [];
683
+ const result = entry.result;
684
+ if (!isRecord(result)) return [];
685
+ const r = result as Record<string, unknown>;
686
+ const data = r.data;
687
+ if (!isRecord(data)) return [];
688
+ const d = data as Record<string, unknown>;
689
+ const paths: string[] = [];
690
+
691
+ // Direct `filePath: string` — presentMulmoScript, presentHtml.
692
+ if (typeof d.filePath === "string" && d.filePath.length > 0) {
693
+ paths.push(d.filePath);
694
+ }
695
+
696
+ // Wiki uses `pageName: string` and stores the page at
697
+ // `wiki/pages/<pageName>.md`. The plugin itself doesn't surface
698
+ // the full path in the result, so we synthesise it from the
699
+ // convention established in server/routes/wiki.ts.
700
+ if (r.toolName === "manageWiki" && typeof d.pageName === "string") {
701
+ paths.push(`wiki/pages/${d.pageName}.md`);
702
+ }
703
+
704
+ // Paths must be workspace-relative (not absolute, no escape).
705
+ // Drop anything suspicious rather than link to it.
706
+ return paths.filter(isSafeWorkspacePath);
707
+ }
708
+
709
+ // Defensive: refuse absolute paths, parent-escapes, or scheme-like
710
+ // strings. Protects against a malformed tool result wedging a
711
+ // filesystem-absolute path into the archivist prompt.
712
+ function isSafeWorkspacePath(p: string): boolean {
713
+ if (!p) return false;
714
+ if (p.startsWith("/")) return false;
715
+ if (p.startsWith("..")) return false;
716
+ if (p.includes("://")) return false;
717
+ return true;
718
+ }
719
+
720
+ function truncate(s: string, max: number): string {
721
+ if (max <= 0) return "";
722
+ if (s.length <= max) return s;
723
+ return `${s.slice(0, max - 1)}…`;
724
+ }
725
+
726
+ async function readAllTopics(workspaceRoot: string): Promise<ExistingTopicSnapshot[]> {
727
+ const topicMap = await readAllTopicFiles(workspaceRoot);
728
+ const out: ExistingTopicSnapshot[] = [];
729
+ for (const [slug, content] of topicMap) {
730
+ out.push({ slug, content });
731
+ }
732
+ return out;
733
+ }
734
+
735
+ async function applyTopicUpdate(workspaceRoot: string, update: TopicUpdate): Promise<"created" | "updated"> {
736
+ if (update.action === "create" || update.action === "append") {
737
+ return appendOrCreateTopic(update.slug, update.content, workspaceRoot);
738
+ }
739
+ // rewrite
740
+ const existed = (await readTopicFile(update.slug, workspaceRoot)) !== null;
741
+ await writeTopicFile(update.slug, update.content, workspaceRoot);
742
+ return existed ? "updated" : "created";
743
+ }