mulmoclaude 0.3.0 → 0.5.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 (312) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-DiKaqnKs.js +5 -0
  3. package/client/assets/{index-eHWB79u5.js → index-C94GcmNa.js} +203 -198
  4. package/client/assets/index-CY-WpQUm.css +2 -0
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-5ipqh8Pe.js} +5 -5
  6. package/client/assets/material-symbols-outlined-NzYEeyps.woff2 +0 -0
  7. package/client/index.html +2 -4
  8. package/package.json +17 -15
  9. package/server/agent/attachmentConverter.ts +2 -2
  10. package/server/agent/backend/claude-code.ts +170 -0
  11. package/server/agent/backend/index.ts +14 -0
  12. package/server/agent/backend/types.ts +65 -0
  13. package/server/agent/index.ts +31 -159
  14. package/server/agent/mcp-server.ts +88 -10
  15. package/server/agent/mcp-tools/index.ts +8 -7
  16. package/server/agent/mcp-tools/notify.ts +76 -0
  17. package/server/agent/mcp-tools/x.ts +12 -2
  18. package/server/agent/plugin-names.ts +10 -4
  19. package/server/agent/prompt.ts +187 -26
  20. package/server/agent/resumeFailover.ts +5 -5
  21. package/server/agent/sandboxMounts.ts +3 -3
  22. package/server/api/auth/bearerAuth.ts +3 -3
  23. package/server/api/auth/token.ts +2 -2
  24. package/server/api/routes/agent.ts +99 -4
  25. package/server/api/routes/chart.ts +13 -0
  26. package/server/api/routes/chat-index.ts +2 -1
  27. package/server/api/routes/config.ts +35 -8
  28. package/server/api/routes/files.ts +75 -24
  29. package/server/api/routes/html.ts +15 -2
  30. package/server/api/routes/image.ts +75 -20
  31. package/server/api/routes/mulmo-script.ts +33 -31
  32. package/server/api/routes/news.ts +146 -0
  33. package/server/api/routes/notifications.ts +58 -2
  34. package/server/api/routes/pdf.ts +2 -2
  35. package/server/api/routes/plugins.ts +73 -91
  36. package/server/api/routes/presentHtml.ts +9 -0
  37. package/server/api/routes/roles.ts +12 -2
  38. package/server/api/routes/scheduler.ts +20 -11
  39. package/server/api/routes/schedulerTasks.ts +58 -21
  40. package/server/api/routes/sessions.ts +15 -4
  41. package/server/api/routes/sessionsCursor.ts +4 -4
  42. package/server/api/routes/skills.ts +26 -5
  43. package/server/api/routes/sources.ts +8 -7
  44. package/server/api/routes/todos.ts +30 -0
  45. package/server/api/routes/todosColumnsHandlers.ts +13 -27
  46. package/server/api/routes/todosHandlers.ts +1 -1
  47. package/server/api/routes/todosItemsHandlers.ts +14 -14
  48. package/server/api/routes/wiki/frontmatter.ts +86 -0
  49. package/server/api/routes/wiki.ts +335 -75
  50. package/server/api/sandboxStatus.ts +1 -1
  51. package/server/events/notifications.ts +32 -8
  52. package/server/events/pub-sub/index.ts +3 -3
  53. package/server/events/relay-client.ts +26 -16
  54. package/server/events/resolveRelayBridgeOptions.ts +125 -0
  55. package/server/index.ts +72 -49
  56. package/server/system/config.ts +5 -5
  57. package/server/system/credentials.ts +7 -5
  58. package/server/system/env.ts +15 -5
  59. package/server/system/macosNotify.ts +152 -0
  60. package/server/utils/errors.ts +11 -2
  61. package/server/utils/fetch.ts +54 -0
  62. package/server/utils/files/atomic.ts +18 -17
  63. package/server/utils/files/image-store.ts +19 -13
  64. package/server/utils/files/journal-io.ts +2 -2
  65. package/server/utils/files/json.ts +5 -5
  66. package/server/utils/files/markdown-image-fill.ts +131 -0
  67. package/server/utils/files/markdown-store.ts +22 -6
  68. package/server/utils/files/naming.ts +20 -10
  69. package/server/utils/files/reference-dirs-io.ts +3 -3
  70. package/server/utils/files/roles-io.ts +4 -4
  71. package/server/utils/files/safe.ts +14 -14
  72. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  73. package/server/utils/files/spreadsheet-store.ts +15 -10
  74. package/server/utils/files/workspace-io.ts +12 -12
  75. package/server/utils/gemini.ts +30 -4
  76. package/server/utils/gitignore.ts +9 -9
  77. package/server/utils/id.ts +40 -8
  78. package/server/utils/json.ts +5 -5
  79. package/server/utils/logBackgroundError.ts +12 -3
  80. package/server/utils/logPreview.ts +24 -0
  81. package/server/utils/markdown.ts +5 -5
  82. package/server/utils/port.d.mts +6 -0
  83. package/server/utils/port.mjs +48 -0
  84. package/server/utils/promptMeta.ts +32 -0
  85. package/server/utils/request.ts +12 -6
  86. package/server/utils/slug.ts +65 -4
  87. package/server/utils/spawn.ts +1 -1
  88. package/server/utils/types.ts +2 -2
  89. package/server/workspace/chat-index/index.ts +1 -1
  90. package/server/workspace/chat-index/summarizer.ts +5 -5
  91. package/server/workspace/custom-dirs.ts +5 -5
  92. package/server/workspace/helps/gemini.md +57 -0
  93. package/server/workspace/helps/index.md +2 -1
  94. package/server/workspace/helps/sources.md +42 -0
  95. package/server/workspace/helps/wiki.md +40 -5
  96. package/server/workspace/journal/archivist-cli.ts +121 -0
  97. package/server/workspace/journal/{archivist.ts → archivist-schemas.ts} +12 -120
  98. package/server/workspace/journal/dailyPass.ts +78 -38
  99. package/server/workspace/journal/diff.ts +2 -2
  100. package/server/workspace/journal/index.ts +56 -5
  101. package/server/workspace/journal/memoryExtractor.ts +1 -1
  102. package/server/workspace/journal/optimizationPass.ts +4 -5
  103. package/server/workspace/journal/paths.ts +8 -24
  104. package/server/workspace/journal/state.ts +18 -8
  105. package/server/workspace/news/reader.ts +248 -0
  106. package/server/workspace/paths.ts +4 -3
  107. package/server/workspace/reference-dirs.ts +3 -3
  108. package/server/workspace/skills/parser.ts +6 -6
  109. package/server/workspace/skills/scheduler.ts +5 -4
  110. package/server/workspace/skills/user-tasks.ts +3 -2
  111. package/server/workspace/skills/writer.ts +3 -3
  112. package/server/workspace/sources/arxivDiscovery.ts +2 -2
  113. package/server/workspace/sources/classifier.ts +1 -1
  114. package/server/workspace/sources/fetchers/rss.ts +5 -5
  115. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  116. package/server/workspace/sources/interests.ts +3 -3
  117. package/server/workspace/sources/paths.ts +6 -6
  118. package/server/workspace/sources/pipeline/fetch.ts +59 -13
  119. package/server/workspace/sources/pipeline/index.ts +59 -7
  120. package/server/workspace/sources/pipeline/notify.ts +13 -5
  121. package/server/workspace/sources/pipeline/plan.ts +11 -9
  122. package/server/workspace/sources/pipeline/summarize.ts +1 -1
  123. package/server/workspace/sources/pipeline/write.ts +5 -5
  124. package/server/workspace/sources/rateLimiter.ts +1 -1
  125. package/server/workspace/sources/sourceState.ts +9 -4
  126. package/server/workspace/sources/types.ts +9 -0
  127. package/server/workspace/sources/urls.ts +1 -1
  128. package/server/workspace/tool-trace/classify.ts +4 -4
  129. package/server/workspace/workspace.ts +7 -7
  130. package/src/App.vue +477 -251
  131. package/src/components/CanvasViewToggle.vue +12 -10
  132. package/src/components/ChatInput.vue +112 -105
  133. package/src/components/FileContentHeader.vue +10 -7
  134. package/src/components/FileContentRenderer.vue +37 -10
  135. package/src/components/FileTree.vue +34 -4
  136. package/src/components/FileTreePane.vue +32 -27
  137. package/src/components/FilesView.vue +5 -3
  138. package/src/components/FilterChip.vue +22 -0
  139. package/src/components/LockStatusPopup.vue +19 -13
  140. package/src/components/NewsView.vue +252 -0
  141. package/src/components/NotificationBell.vue +35 -9
  142. package/src/components/NotificationToast.vue +4 -1
  143. package/src/components/PageChatComposer.vue +101 -0
  144. package/src/components/PluginLauncher.vue +36 -62
  145. package/src/components/RightSidebar.vue +13 -10
  146. package/src/components/RoleSelector.vue +3 -2
  147. package/src/components/SessionHeaderControls.vue +63 -0
  148. package/src/components/SessionHistoryExpandButton.vue +30 -0
  149. package/src/components/SessionHistoryPanel.vue +64 -93
  150. package/src/components/SessionHistoryToggleButton.vue +40 -0
  151. package/src/components/SessionRoleIcon.vue +72 -0
  152. package/src/components/SessionSidebar.vue +96 -0
  153. package/src/components/SessionTabBar.vue +44 -51
  154. package/src/components/SettingsMcpTab.vue +361 -52
  155. package/src/components/SettingsModal.vue +203 -72
  156. package/src/components/SettingsReferenceDirsTab.vue +72 -51
  157. package/src/components/SettingsWorkspaceDirsTab.vue +74 -51
  158. package/src/components/SidebarHeader.vue +50 -16
  159. package/src/components/SourcesManager.vue +900 -0
  160. package/src/components/SourcesView.vue +45 -0
  161. package/src/components/StackView.vue +84 -48
  162. package/src/components/SuggestionsPanel.vue +25 -36
  163. package/src/components/SystemFileBanner.vue +106 -0
  164. package/src/components/ThinkingIndicator.vue +41 -0
  165. package/src/components/TodoExplorer.vue +72 -22
  166. package/src/components/todo/TodoAddDialog.vue +17 -12
  167. package/src/components/todo/TodoEditDialog.vue +7 -2
  168. package/src/components/todo/TodoEditPanel.vue +15 -10
  169. package/src/components/todo/TodoKanbanView.vue +16 -6
  170. package/src/components/todo/TodoListView.vue +14 -3
  171. package/src/components/todo/TodoTableView.vue +36 -5
  172. package/src/composables/favicon/conditions.ts +76 -0
  173. package/src/composables/favicon/resolveColor.ts +93 -0
  174. package/src/composables/favicon/types.ts +61 -0
  175. package/src/composables/useAppApi.ts +23 -0
  176. package/src/composables/useChatScroll.ts +5 -5
  177. package/src/composables/useCurrentRole.ts +32 -0
  178. package/src/composables/useDynamicFavicon.ts +174 -58
  179. package/src/composables/useEventListeners.ts +7 -12
  180. package/src/composables/useFaviconState.ts +93 -12
  181. package/src/composables/useFileSelection.ts +25 -6
  182. package/src/composables/useHealth.ts +76 -7
  183. package/src/composables/useLayoutMode.ts +27 -0
  184. package/src/composables/useNewsItems.ts +38 -0
  185. package/src/composables/useNewsReadState.ts +75 -0
  186. package/src/composables/useNotifications.ts +76 -13
  187. package/src/composables/usePendingCalls.ts +11 -1
  188. package/src/composables/useRoles.ts +6 -10
  189. package/src/composables/useRunElapsed.ts +80 -0
  190. package/src/composables/useSessionDerived.ts +21 -5
  191. package/src/composables/useSessionHistory.ts +7 -17
  192. package/src/composables/useSidePanelVisible.ts +25 -0
  193. package/src/composables/useViewLayout.ts +16 -37
  194. package/src/config/apiRoutes.ts +19 -6
  195. package/src/config/historyFilters.ts +30 -0
  196. package/src/config/mcpCatalog.ts +285 -0
  197. package/src/config/mcpTypes.ts +26 -0
  198. package/src/config/roles.ts +19 -51
  199. package/src/config/systemFileDescriptors.ts +170 -0
  200. package/src/config/toolNames.ts +6 -1
  201. package/src/config/workspacePaths.ts +1 -0
  202. package/src/index.css +14 -0
  203. package/src/lang/de.ts +706 -0
  204. package/src/lang/en.ts +726 -0
  205. package/src/lang/es.ts +712 -0
  206. package/src/lang/fr.ts +704 -0
  207. package/src/lang/ja.ts +707 -0
  208. package/src/lang/ko.ts +709 -0
  209. package/src/lang/pt-BR.ts +702 -0
  210. package/src/lang/zh.ts +705 -0
  211. package/src/lib/vue-i18n.ts +97 -0
  212. package/src/main.ts +3 -0
  213. package/src/plugins/canvas/View.vue +104 -186
  214. package/src/plugins/canvas/definition.ts +0 -8
  215. package/src/plugins/canvas/index.ts +3 -2
  216. package/src/plugins/chart/Preview.vue +1 -1
  217. package/src/plugins/chart/View.vue +9 -4
  218. package/src/plugins/chart/index.ts +3 -2
  219. package/src/plugins/editImage/index.ts +3 -2
  220. package/src/plugins/generateImage/index.ts +3 -2
  221. package/src/plugins/manageRoles/Preview.vue +4 -1
  222. package/src/plugins/manageRoles/View.vue +67 -46
  223. package/src/plugins/manageRoles/index.ts +3 -2
  224. package/src/plugins/manageSkills/Preview.vue +8 -3
  225. package/src/plugins/manageSkills/View.vue +39 -34
  226. package/src/plugins/manageSkills/index.ts +3 -2
  227. package/src/plugins/manageSource/Preview.vue +1 -1
  228. package/src/plugins/manageSource/View.vue +3 -687
  229. package/src/plugins/manageSource/index.ts +3 -2
  230. package/src/plugins/markdown/Preview.vue +1 -1
  231. package/src/plugins/markdown/View.vue +164 -73
  232. package/src/plugins/markdown/definition.ts +6 -4
  233. package/src/plugins/markdown/index.ts +3 -2
  234. package/src/plugins/presentForm/Preview.vue +99 -0
  235. package/src/plugins/presentForm/View.vue +675 -0
  236. package/src/plugins/presentForm/definition.ts +127 -0
  237. package/src/plugins/presentForm/index.ts +18 -0
  238. package/src/plugins/presentForm/plugin.ts +94 -0
  239. package/src/plugins/presentForm/types.ts +109 -0
  240. package/src/plugins/presentHtml/Preview.vue +1 -1
  241. package/src/plugins/presentHtml/View.vue +7 -4
  242. package/src/plugins/presentHtml/index.ts +3 -2
  243. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  244. package/src/plugins/presentMulmoScript/View.vue +36 -26
  245. package/src/plugins/presentMulmoScript/index.ts +3 -2
  246. package/src/plugins/scheduler/AutomationsPreview.vue +37 -0
  247. package/src/plugins/scheduler/AutomationsView.vue +23 -0
  248. package/src/plugins/scheduler/CalendarView.vue +23 -0
  249. package/src/plugins/scheduler/LegacySchedulerView.vue +32 -0
  250. package/src/plugins/scheduler/Preview.vue +7 -4
  251. package/src/plugins/scheduler/TasksTab.vue +119 -28
  252. package/src/plugins/scheduler/View.vue +75 -32
  253. package/src/plugins/scheduler/automationsDefinition.ts +58 -0
  254. package/src/plugins/scheduler/calendarDefinition.ts +46 -0
  255. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  256. package/src/plugins/scheduler/index.ts +68 -14
  257. package/src/plugins/scheduler/legacyShape.ts +34 -0
  258. package/src/plugins/spreadsheet/Preview.vue +9 -5
  259. package/src/plugins/spreadsheet/View.vue +43 -57
  260. package/src/plugins/spreadsheet/engine/responseDecoder.ts +2 -1
  261. package/src/plugins/spreadsheet/index.ts +3 -2
  262. package/src/plugins/textResponse/Preview.vue +15 -58
  263. package/src/plugins/textResponse/View.vue +42 -45
  264. package/src/plugins/textResponse/utils.ts +25 -0
  265. package/src/plugins/todo/Preview.vue +11 -6
  266. package/src/plugins/todo/View.vue +27 -13
  267. package/src/plugins/todo/composables/useTodos.ts +3 -1
  268. package/src/plugins/todo/index.ts +3 -2
  269. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  270. package/src/plugins/ui-image/ImageView.vue +7 -4
  271. package/src/plugins/wiki/Preview.vue +5 -2
  272. package/src/plugins/wiki/View.vue +539 -92
  273. package/src/plugins/wiki/index.ts +5 -2
  274. package/src/plugins/wiki/route.ts +121 -0
  275. package/src/router/guards.ts +43 -24
  276. package/src/router/index.ts +53 -26
  277. package/src/router/pageRoutes.ts +23 -0
  278. package/src/tools/index.ts +12 -5
  279. package/src/tools/legacyPluginNames.ts +13 -0
  280. package/src/types/notification.ts +31 -6
  281. package/src/types/vue-i18n.d.ts +20 -0
  282. package/src/utils/agent/eventDispatch.ts +3 -6
  283. package/src/utils/agent/formatElapsed.ts +37 -0
  284. package/src/utils/agent/request.ts +22 -1
  285. package/src/utils/canvas/layoutMode.ts +26 -0
  286. package/src/utils/canvas/sidePanelVisible.ts +19 -0
  287. package/src/utils/dom/scrollIntoViewByTestId.ts +38 -0
  288. package/src/utils/errors.ts +9 -2
  289. package/src/utils/files/filename.ts +24 -0
  290. package/src/utils/filesPreview/schedulerPreview.ts +9 -3
  291. package/src/utils/id.ts +18 -0
  292. package/src/utils/image/cacheBust.ts +16 -0
  293. package/src/utils/image/resolve.ts +16 -0
  294. package/src/utils/markdown/taskList.ts +175 -0
  295. package/src/utils/mcp/interpolateSpec.ts +97 -0
  296. package/src/utils/notification/dispatch.ts +51 -15
  297. package/src/utils/path/workspaceLinkRouter.ts +99 -0
  298. package/src/utils/session/mergeSessions.ts +5 -0
  299. package/src/utils/sources/filter.ts +69 -0
  300. package/src/vite-env.d.ts +9 -0
  301. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  302. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  303. package/client/assets/index-Bm70FDU2.css +0 -1
  304. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  305. package/server/workspace/journal/linkRewrite.ts +0 -4
  306. package/src/components/ToolResultsPanel.vue +0 -77
  307. package/src/composables/useCanvasViewMode.ts +0 -121
  308. package/src/plugins/scheduler/definition.ts +0 -57
  309. package/src/utils/canvas/viewMode.ts +0 -46
  310. package/src/utils/role/plugins.ts +0 -12
  311. package/src/utils/session/seedRoleDefault.ts +0 -35
  312. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -1,8 +1,9 @@
1
1
  import { Router, Request, Response } from "express";
2
- import fs from "fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs";
3
3
  import path from "path";
4
4
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
5
5
  import { stripDataUri } from "../../utils/files/image-store.js";
6
+ import { writeJsonAtomic } from "../../utils/files/json.js";
6
7
  import {
7
8
  getFileObject,
8
9
  initializeContextFromFiles,
@@ -26,6 +27,7 @@ import { slugify } from "../../utils/slug.js";
26
27
  import { resolveWithinRoot } from "../../utils/files/safe.js";
27
28
  import { errorMessage } from "../../utils/errors.js";
28
29
  import { badRequest, notFound, serverError } from "../../utils/httpError.js";
30
+ import { getOptionalStringQuery } from "../../utils/request.js";
29
31
  import { log } from "../../system/logger/index.js";
30
32
  import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
31
33
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
@@ -54,8 +56,8 @@ let storiesRealCache: string | null = null;
54
56
  function ensureStoriesReal(): string | null {
55
57
  if (storiesRealCache) return storiesRealCache;
56
58
  try {
57
- fs.mkdirSync(storiesDir, { recursive: true });
58
- storiesRealCache = fs.realpathSync(storiesDir);
59
+ mkdirSync(storiesDir, { recursive: true });
60
+ storiesRealCache = realpathSync(storiesDir);
59
61
  return storiesRealCache;
60
62
  } catch {
61
63
  return null;
@@ -96,7 +98,7 @@ interface FilePathQuery {
96
98
  filePath?: string;
97
99
  }
98
100
 
99
- router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
101
+ router.post(API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
100
102
  const { script, filename } = req.body;
101
103
 
102
104
  if (!script || !Array.isArray(script.beats)) {
@@ -104,14 +106,14 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
104
106
  return;
105
107
  }
106
108
 
107
- fs.mkdirSync(storiesDir, { recursive: true });
109
+ mkdirSync(storiesDir, { recursive: true });
108
110
 
109
111
  const title = script.title || "untitled";
110
112
  const slug = filename ? filename.replace(/\.json$/, "") : slugify(title);
111
113
  const fname = `${slug}-${Date.now()}.json`;
112
114
  const filePath = path.join(storiesDir, fname);
113
115
 
114
- fs.writeFileSync(filePath, JSON.stringify(script, null, 2));
116
+ await writeJsonAtomic(filePath, script);
115
117
 
116
118
  res.json({
117
119
  data: { script, filePath: `stories/${fname}` },
@@ -120,7 +122,7 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
120
122
  });
121
123
  });
122
124
 
123
- router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unknown>, res: Response) => {
125
+ router.post(API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
124
126
  const validation = validateUpdateBeatBody(req.body);
125
127
  if (!validation.ok) {
126
128
  badRequest(res, validation.error);
@@ -131,7 +133,7 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
131
133
  const absoluteFilePath = resolveStoryPath(filePath, res);
132
134
  if (!absoluteFilePath) return;
133
135
 
134
- const script: MulmoScript = JSON.parse(fs.readFileSync(absoluteFilePath, "utf-8"));
136
+ const script: MulmoScript = JSON.parse(readFileSync(absoluteFilePath, "utf-8"));
135
137
 
136
138
  if (!Array.isArray(script.beats) || beatIndex >= script.beats.length) {
137
139
  badRequest(res, "Invalid beatIndex");
@@ -139,12 +141,12 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
139
141
  }
140
142
 
141
143
  script.beats[beatIndex] = beat as MulmoBeat;
142
- fs.writeFileSync(absoluteFilePath, JSON.stringify(script, null, 2));
144
+ await writeJsonAtomic(absoluteFilePath, script);
143
145
 
144
146
  res.json({ ok: true });
145
147
  });
146
148
 
147
- router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, unknown>, res: Response) => {
149
+ router.post(API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
148
150
  const validation = validateUpdateScriptBody(req.body);
149
151
  if (!validation.ok) {
150
152
  badRequest(res, validation.error);
@@ -155,7 +157,7 @@ router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, u
155
157
  const absoluteFilePath = resolveStoryPath(filePath, res);
156
158
  if (!absoluteFilePath) return;
157
159
 
158
- fs.writeFileSync(absoluteFilePath, JSON.stringify(updatedScript, null, 2));
160
+ await writeJsonAtomic(absoluteFilePath, updatedScript);
159
161
  res.json({ ok: true });
160
162
  });
161
163
 
@@ -170,7 +172,7 @@ router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatIma
170
172
 
171
173
  await withStoryContext(res, filePath, {}, async ({ context }) => {
172
174
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
173
- if (!fs.existsSync(imagePath)) {
175
+ if (!existsSync(imagePath)) {
174
176
  res.json({ image: null });
175
177
  return;
176
178
  }
@@ -197,13 +199,13 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
197
199
  }
198
200
 
199
201
  const outputPath = movieFilePath(context);
200
- if (!fs.existsSync(outputPath)) {
202
+ if (!existsSync(outputPath)) {
201
203
  res.json({ moviePath: null });
202
204
  return;
203
205
  }
204
206
 
205
- const movieMtime = fs.statSync(outputPath).mtimeMs;
206
- const sourceMtime = fs.statSync(absoluteFilePath).mtimeMs;
207
+ const movieMtime = statSync(outputPath).mtimeMs;
208
+ const sourceMtime = statSync(absoluteFilePath).mtimeMs;
207
209
  if (movieMtime < sourceMtime) {
208
210
  res.json({ moviePath: null });
209
211
  return;
@@ -216,7 +218,7 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
216
218
  });
217
219
 
218
220
  function fileToDataUri(filePath: string, mimeType: string): string {
219
- const data = fs.readFileSync(filePath);
221
+ const data = readFileSync(filePath);
220
222
  return `data:${mimeType};base64,${data.toString("base64")}`;
221
223
  }
222
224
 
@@ -257,7 +259,7 @@ function resolveStoryPath(filePath: string, res: Response): string | null {
257
259
  const resolved = resolveWithinRoot(storiesReal, relFromStories);
258
260
  if (!resolved) {
259
261
  const candidate = path.resolve(storiesReal, relFromStories);
260
- if (!fs.existsSync(candidate)) {
262
+ if (!existsSync(candidate)) {
261
263
  notFound(res, `File not found: ${filePath}`);
262
264
  } else {
263
265
  badRequest(res, "Invalid filePath");
@@ -373,12 +375,12 @@ router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAud
373
375
  filePath,
374
376
  {
375
377
  operation: "beat-audio",
376
- onContextMissing: (r) => r.json({ audio: null }),
378
+ onContextMissing: (response) => response.json({ audio: null }),
377
379
  },
378
380
  async ({ context }) => {
379
381
  const beat = context.studio.script.beats[beatIndex];
380
382
  const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
381
- if (!audioPath || !fs.existsSync(audioPath)) {
383
+ if (!audioPath || !existsSync(audioPath)) {
382
384
  res.json({ audio: null });
383
385
  return;
384
386
  }
@@ -422,7 +424,7 @@ router.post(
422
424
  const beat = context.studio.script.beats[beatIndex];
423
425
  const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
424
426
 
425
- if (!audioPath || !fs.existsSync(audioPath)) {
427
+ if (!audioPath || !existsSync(audioPath)) {
426
428
  // Logic-flow failure (not an exception) — emit a targeted
427
429
  // log. Don't write raw `beat.text` into persistent logs —
428
430
  // it's free-form user content and can contain sensitive
@@ -430,7 +432,7 @@ router.post(
430
432
  log.error("generate-beat-audio", "audio was not generated", {
431
433
  beatIndex,
432
434
  audioPath,
433
- exists: audioPath ? fs.existsSync(audioPath) : false,
435
+ exists: audioPath ? existsSync(audioPath) : false,
434
436
  beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
435
437
  audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile),
436
438
  });
@@ -475,7 +477,7 @@ router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, objec
475
477
  });
476
478
 
477
479
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
478
- if (!fs.existsSync(imagePath)) {
480
+ if (!existsSync(imagePath)) {
479
481
  genError = "Image was not generated";
480
482
  serverError(res, genError);
481
483
  return;
@@ -542,7 +544,7 @@ router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, ob
542
544
  await movie(audioContext);
543
545
 
544
546
  const outputPath = movieFilePath(audioContext);
545
- if (!fs.existsSync(outputPath)) {
547
+ if (!existsSync(outputPath)) {
546
548
  genError = "Movie was not generated";
547
549
  send({ type: "error", message: genError });
548
550
  res.end();
@@ -594,7 +596,7 @@ router.get(
594
596
 
595
597
  await withStoryContext(res, filePath, {}, async ({ context }) => {
596
598
  const imagePath = getReferenceImagePath(context, key, "png");
597
- if (!fs.existsSync(imagePath)) {
599
+ if (!existsSync(imagePath)) {
598
600
  res.json({ image: null });
599
601
  return;
600
602
  }
@@ -613,10 +615,10 @@ router.post(API_ROUTES.mulmoScript.uploadBeatImage, async (req: Request<object,
613
615
 
614
616
  await withStoryContext(res, filePath, {}, async ({ context }) => {
615
617
  const { imagePath } = getBeatPngImagePath(context, beatIndex);
616
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
618
+ mkdirSync(path.dirname(imagePath), { recursive: true });
617
619
 
618
620
  const base64 = stripDataUri(imageData);
619
- fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
621
+ writeFileSync(imagePath, Buffer.from(base64, "base64"));
620
622
 
621
623
  res.json({ image: fileToDataUri(imagePath, "image/png") });
622
624
  });
@@ -647,7 +649,7 @@ router.post(
647
649
 
648
650
  const index = Object.keys(images).indexOf(key);
649
651
  const imagePath = getReferenceImagePath(context, key, "png");
650
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
652
+ mkdirSync(path.dirname(imagePath), { recursive: true });
651
653
 
652
654
  await generateReferenceImage({
653
655
  context,
@@ -656,7 +658,7 @@ router.post(
656
658
  image: imageEntry as MulmoImagePromptMedia,
657
659
  force,
658
660
  });
659
- if (!fs.existsSync(imagePath)) {
661
+ if (!existsSync(imagePath)) {
660
662
  genError = "Character image was not generated";
661
663
  serverError(res, genError);
662
664
  return;
@@ -685,10 +687,10 @@ router.post(
685
687
 
686
688
  await withStoryContext(res, filePath, {}, async ({ context }) => {
687
689
  const imagePath = getReferenceImagePath(context, key, "png");
688
- fs.mkdirSync(path.dirname(imagePath), { recursive: true });
690
+ mkdirSync(path.dirname(imagePath), { recursive: true });
689
691
 
690
692
  const base64 = stripDataUri(imageData);
691
- fs.writeFileSync(imagePath, Buffer.from(base64, "base64"));
693
+ writeFileSync(imagePath, Buffer.from(base64, "base64"));
692
694
 
693
695
  res.json({ image: fileToDataUri(imagePath, "image/png") });
694
696
  });
@@ -696,7 +698,7 @@ router.post(
696
698
  );
697
699
 
698
700
  router.get(API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
699
- const moviePath = typeof req.query.moviePath === "string" ? req.query.moviePath : undefined;
701
+ const moviePath = getOptionalStringQuery(req, "moviePath");
700
702
 
701
703
  if (!moviePath) {
702
704
  badRequest(res, "moviePath is required");
@@ -0,0 +1,146 @@
1
+ // HTTP routes for the news viewer (#761).
2
+ //
3
+ // GET /api/news/items?days=N — recent items, sorted desc
4
+ // GET /api/news/items/:id/body — body markdown from per-source archive
5
+ // GET /api/news/read-state — { readIds: string[] }
6
+ // PUT /api/news/read-state — { readIds: string[] }
7
+ //
8
+ // The reader walks daily JSON indexes on demand (see
9
+ // `server/workspace/news/reader.ts`); no separate index is
10
+ // maintained — see issue #761 for the design rationale (covers ~30
11
+ // days, follow-up ticket for full history).
12
+
13
+ import path from "node:path";
14
+ import { Router, type Request, type Response } from "express";
15
+ import { workspacePath } from "../../workspace/workspace.js";
16
+ import { aggregateRecentItems, loadItemBody, type NewsItem } from "../../workspace/news/reader.js";
17
+ import { loadJsonFile, writeJsonAtomic } from "../../utils/files/json.js";
18
+ import { WORKSPACE_FILES } from "../../../src/config/workspacePaths.js";
19
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
20
+ import { badRequest, serverError } from "../../utils/httpError.js";
21
+ import { errorMessage } from "../../utils/errors.js";
22
+ import { isRecord } from "../../utils/types.js";
23
+ import { log } from "../../system/logger/index.js";
24
+
25
+ // Window upper bound — keeps memory bounded if a caller passes
26
+ // a comically large `days`. Daily JSON aggregation is O(days * items)
27
+ // in the worst case.
28
+ const MAX_DAYS = 90;
29
+ const DEFAULT_DAYS = 30;
30
+
31
+ // Cap the read-state list size. UUID-shaped ids are ~16 bytes each;
32
+ // 10k of them is < 200 KB, fine. Beyond that we evict from the front
33
+ // (oldest mark-as-read first) so the working set stays small.
34
+ const MAX_READ_IDS = 10_000;
35
+
36
+ interface ReadState {
37
+ readIds: string[];
38
+ }
39
+
40
+ const router = Router();
41
+
42
+ // ── /api/news/items ─────────────────────────────────────────────
43
+
44
+ router.get(API_ROUTES.news.items, async (req: Request, res: Response<{ items: NewsItem[] } | { error: string }>) => {
45
+ const days = parseDays(req.query.days);
46
+ if (days === null) {
47
+ badRequest(res, "invalid `days` query parameter");
48
+ return;
49
+ }
50
+ try {
51
+ const items = await aggregateRecentItems(workspacePath, days);
52
+ res.json({ items });
53
+ } catch (err) {
54
+ log.error("news", "aggregate failed", { error: errorMessage(err) });
55
+ serverError(res, "failed to load news items");
56
+ }
57
+ });
58
+
59
+ function parseDays(raw: unknown): number | null {
60
+ if (raw === undefined) return DEFAULT_DAYS;
61
+ const value = Number(Array.isArray(raw) ? raw[0] : raw);
62
+ if (!Number.isInteger(value) || value <= 0) return null;
63
+ return Math.min(value, MAX_DAYS);
64
+ }
65
+
66
+ // ── /api/news/items/:id/body ─────────────────────────────────────
67
+ //
68
+ // Body lookup needs the item's `sourceSlug` + `publishedAt` to
69
+ // pick the right archive file. We re-aggregate on demand (cheap;
70
+ // could be cached later if it shows up in a hot path). The
71
+ // frontend has the metadata already, but routing it through query
72
+ // params would let an attacker fabricate paths — so we resolve from
73
+ // the trusted in-memory aggregate keyed by `id` here.
74
+
75
+ router.get(API_ROUTES.news.itemBody, async (req: Request<{ id: string }>, res: Response<{ body: string | null } | { error: string }>) => {
76
+ const { id } = req.params;
77
+ if (!id) {
78
+ badRequest(res, "missing item id");
79
+ return;
80
+ }
81
+ try {
82
+ const items = await aggregateRecentItems(workspacePath, MAX_DAYS);
83
+ const match = items.find((entry) => entry.id === id);
84
+ if (!match) {
85
+ res.json({ body: null });
86
+ return;
87
+ }
88
+ const body = await loadItemBody(workspacePath, match.sourceSlug, match.url, match.publishedAt);
89
+ res.json({ body });
90
+ } catch (err) {
91
+ log.error("news", "body lookup failed", { error: errorMessage(err) });
92
+ serverError(res, "failed to load item body");
93
+ }
94
+ });
95
+
96
+ // ── /api/news/read-state ─────────────────────────────────────────
97
+
98
+ const readStateAbsPath = (): string => path.join(workspacePath, WORKSPACE_FILES.newsReadState);
99
+
100
+ router.get(API_ROUTES.news.readState, (_req: Request, res: Response<ReadState | { error: string }>) => {
101
+ try {
102
+ const data = loadJsonFile<ReadState>(readStateAbsPath(), { readIds: [] });
103
+ const sanitized = sanitizeReadState(data);
104
+ res.json(sanitized);
105
+ } catch (err) {
106
+ log.error("news", "read-state load failed", {
107
+ error: errorMessage(err),
108
+ });
109
+ serverError(res, "failed to load news read-state");
110
+ }
111
+ });
112
+
113
+ router.put(API_ROUTES.news.readState, async (req: Request, res: Response<ReadState | { error: string }>) => {
114
+ const body = req.body;
115
+ if (!isRecord(body) || !Array.isArray(body.readIds)) {
116
+ badRequest(res, "expected { readIds: string[] }");
117
+ return;
118
+ }
119
+ const sanitized = sanitizeReadState({ readIds: body.readIds });
120
+ try {
121
+ await writeJsonAtomic(readStateAbsPath(), sanitized);
122
+ res.json(sanitized);
123
+ } catch (err) {
124
+ log.error("news", "read-state save failed", {
125
+ error: errorMessage(err),
126
+ });
127
+ serverError(res, "failed to save news read-state");
128
+ }
129
+ });
130
+
131
+ // Drop non-string entries, dedupe, cap at MAX_READ_IDS (keeping the
132
+ // most recent — i.e. tail end — of the list). Pure for testability.
133
+ export function sanitizeReadState(input: { readIds: unknown[] }): ReadState {
134
+ const seen = new Set<string>();
135
+ const ordered: string[] = [];
136
+ for (const value of input.readIds) {
137
+ if (typeof value !== "string" || value.length === 0) continue;
138
+ if (seen.has(value)) continue;
139
+ seen.add(value);
140
+ ordered.push(value);
141
+ }
142
+ if (ordered.length <= MAX_READ_IDS) return { readIds: ordered };
143
+ return { readIds: ordered.slice(ordered.length - MAX_READ_IDS) };
144
+ }
145
+
146
+ export default router;
@@ -2,15 +2,27 @@
2
2
  // simultaneously to every open Web tab (pub-sub) and every bridge
3
3
  // (chat-service `pushToBridge`). Stepping stone for the in-app
4
4
  // notification center (#144) and external-channel notifications
5
- // (#142); see plans/feat-notification-push-scaffold.md for the
5
+ // (#142); see plans/done/feat-notification-push-scaffold.md for the
6
6
  // motivation and the production plan.
7
7
  //
8
- // Usage:
8
+ // Usage (basic):
9
9
  // curl -X POST http://localhost:3001/api/notifications/test \
10
10
  // -H "Authorization: Bearer $(cat ~/mulmoclaude/.session-token)" \
11
11
  // -H "Content-Type: application/json" \
12
12
  // -d '{"message":"hello","delaySeconds":5}'
13
13
  //
14
+ // Usage (with permalink action — #762):
15
+ // curl -X POST http://localhost:3001/api/notifications/test \
16
+ // -H "Authorization: Bearer $TOKEN" \
17
+ // -H "Content-Type: application/json" \
18
+ // -d '{"message":"Scheduled task fired","kind":"scheduler",
19
+ // "action":{"type":"navigate",
20
+ // "target":{"view":"automations",
21
+ // "taskId":"finance-daily-briefing"}}}'
22
+ //
23
+ // For a one-shot "fire one of every target kind" run,
24
+ // scripts/dev/fire-sample-notifications.sh drives this endpoint.
25
+ //
14
26
  // The route is exported as a factory so the host wiring can inject
15
27
  // the pub-sub publisher and the chat-service push handle without
16
28
  // this file pulling in either module directly.
@@ -19,12 +31,23 @@ import { Router, type Request, type Response } from "express";
19
31
  import { scheduleTestNotification, type NotificationDeps, type ScheduleNotificationOptions } from "../../events/notifications.js";
20
32
  import { log } from "../../system/logger/index.js";
21
33
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
34
+ import {
35
+ NOTIFICATION_ACTION_TYPES,
36
+ NOTIFICATION_KINDS,
37
+ NOTIFICATION_VIEWS,
38
+ type NotificationAction,
39
+ type NotificationKind,
40
+ } from "../../../src/types/notification.js";
41
+ import { isRecord } from "../../utils/types.js";
22
42
 
23
43
  interface TestRequestBody {
24
44
  message?: unknown;
45
+ body?: unknown;
25
46
  delaySeconds?: unknown;
26
47
  transportId?: unknown;
27
48
  chatId?: unknown;
49
+ kind?: unknown;
50
+ action?: unknown;
28
51
  }
29
52
 
30
53
  interface TestResponse {
@@ -32,11 +55,40 @@ interface TestResponse {
32
55
  delaySeconds: number;
33
56
  }
34
57
 
58
+ const KIND_SET = new Set<string>(Object.values(NOTIFICATION_KINDS));
59
+ const VIEW_SET = new Set<string>(Object.values(NOTIFICATION_VIEWS));
60
+
61
+ function parseKind(value: unknown): NotificationKind | undefined {
62
+ if (typeof value !== "string") return undefined;
63
+ return KIND_SET.has(value) ? (value as NotificationKind) : undefined;
64
+ }
65
+
66
+ // Loose validator — dev-facing so we prefer "pass through what looks
67
+ // right" over strict type guards. Only checks that `type` is one of
68
+ // the two known literals and, for navigates, that `target.view` is a
69
+ // known view. The target's other fields are forwarded verbatim so
70
+ // adding new optional fields doesn't require editing this file.
71
+ function parseAction(value: unknown): NotificationAction | undefined {
72
+ if (!isRecord(value)) return undefined;
73
+ if (value.type === NOTIFICATION_ACTION_TYPES.none) {
74
+ return { type: NOTIFICATION_ACTION_TYPES.none };
75
+ }
76
+ if (value.type !== NOTIFICATION_ACTION_TYPES.navigate) return undefined;
77
+ const target = value.target;
78
+ if (!isRecord(target) || typeof target.view !== "string" || !VIEW_SET.has(target.view)) {
79
+ return undefined;
80
+ }
81
+ return { type: NOTIFICATION_ACTION_TYPES.navigate, target: target as NotificationAction extends { target: infer T } ? T : never };
82
+ }
83
+
35
84
  function parseBody(body: TestRequestBody): ScheduleNotificationOptions {
36
85
  const opts: ScheduleNotificationOptions = {};
37
86
  if (typeof body.message === "string" && body.message.length > 0) {
38
87
  opts.message = body.message;
39
88
  }
89
+ if (typeof body.body === "string" && body.body.length > 0) {
90
+ opts.body = body.body;
91
+ }
40
92
  if (typeof body.delaySeconds === "number") {
41
93
  opts.delaySeconds = body.delaySeconds;
42
94
  }
@@ -46,6 +98,10 @@ function parseBody(body: TestRequestBody): ScheduleNotificationOptions {
46
98
  if (typeof body.chatId === "string" && body.chatId.length > 0) {
47
99
  opts.chatId = body.chatId;
48
100
  }
101
+ const kind = parseKind(body.kind);
102
+ if (kind) opts.kind = kind;
103
+ const action = parseAction(body.action);
104
+ if (action) opts.action = action;
49
105
  return opts;
50
106
  }
51
107
 
@@ -1,4 +1,4 @@
1
- import fs from "fs";
1
+ import { realpathSync } from "fs";
2
2
  import path from "path";
3
3
  import { Router, Request, Response } from "express";
4
4
  import { marked } from "marked";
@@ -56,7 +56,7 @@ const MIME_BY_EXT: Record<string, string> = {
56
56
  // Realpath of the workspace, resolved once at module load. Used to
57
57
  // validate that image paths resolved relative to markdowns/ stay
58
58
  // inside the workspace after symlink resolution.
59
- const workspaceReal = fs.realpathSync(resolveWorkspacePath(""));
59
+ const workspaceReal = realpathSync(resolveWorkspacePath(""));
60
60
 
61
61
  /**
62
62
  * Inline local images as base64 data URIs so Puppeteer can render them.