mulmoclaude 0.1.2 → 0.4.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 (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -7,8 +7,8 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
7
7
  const router = Router();
8
8
 
9
9
  async function callGemini(prompt: string): Promise<string> {
10
- const ai = getGeminiClient();
11
- const response = await ai.models.generateContent({
10
+ const client = getGeminiClient();
11
+ const response = await client.models.generateContent({
12
12
  model: "gemini-2.0-flash",
13
13
  contents: [{ text: prompt }],
14
14
  });
@@ -4,7 +4,7 @@ import { getSessionImageData } from "../../events/session-store/index.js";
4
4
  import { generateGeminiImageContent, generateGeminiImageFromPrompt } from "../../utils/gemini.js";
5
5
  import { errorMessage } from "../../utils/errors.js";
6
6
  import { badRequest, serverError } from "../../utils/httpError.js";
7
- import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath } from "../../utils/files/image-store.js";
7
+ import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath, imagePathFromFilename } from "../../utils/files/image-store.js";
8
8
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
9
9
 
10
10
  const router = Router();
@@ -148,14 +148,14 @@ router.post(API_ROUTES.image.upload, async (req: Request<object, unknown, Canvas
148
148
  router.put(
149
149
  API_ROUTES.image.update,
150
150
  async (req: Request<{ filename: string }, unknown, CanvasImageBody>, res: Response<CanvasImageResponse | CanvasImageError>) => {
151
- const relativePath = `images/${req.params.filename}`;
152
- const { imageData } = req.body;
153
- if (!imageData || !relativePath) {
154
- badRequest(res, "imageData and path are required");
151
+ const relativePath = imagePathFromFilename(req.params.filename);
152
+ if (!relativePath) {
153
+ badRequest(res, "invalid image filename");
155
154
  return;
156
155
  }
157
- if (!isImagePath(relativePath)) {
158
- badRequest(res, "invalid image path");
156
+ const { imageData } = req.body;
157
+ if (!imageData) {
158
+ badRequest(res, "imageData is required");
159
159
  return;
160
160
  }
161
161
  const base64 = stripDataUri(imageData);
@@ -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");
@@ -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.
@@ -80,10 +80,10 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
80
80
  }
81
81
 
82
82
  const results = await Promise.all(
83
- matches.map(async (m) => ({
84
- full: m[0],
85
- prompt: m[1],
86
- url: geminiOk ? await generateImageFile(m[1]) : null,
83
+ matches.map(async (match) => ({
84
+ full: match[0],
85
+ prompt: match[1],
86
+ url: geminiOk ? await generateImageFile(match[1]) : null,
87
87
  })),
88
88
  );
89
89
 
@@ -91,7 +91,7 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
91
91
  // success rate even when most calls go through. The per-call
92
92
  // error already lands at warn from generateImageFile's catch.
93
93
  if (geminiOk) {
94
- const failed = results.filter((r) => !r.url).length;
94
+ const failed = results.filter((result) => !result.url).length;
95
95
  if (failed > 0) {
96
96
  log.warn("present-document", "image generation had failures", {
97
97
  failed,
@@ -261,10 +261,20 @@ router.post(
261
261
  wrapPluginExecute((req) => executeForm(null as never, req.body)),
262
262
  );
263
263
 
264
+ // 1×1 transparent PNG. Used as a placeholder so the canvas tool
265
+ // result can carry a stable file path from the moment the canvas
266
+ // is opened — client autosaves PUT-overwrite this same file, so the
267
+ // drawing survives page reload with zero client→server sync.
268
+ const BLANK_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
269
+
264
270
  // openCanvas — drawing canvas
265
271
  router.post(
266
272
  API_ROUTES.plugins.canvas,
267
- wrapPluginExecute(() => executeOpenCanvas()),
273
+ wrapPluginExecute(async () => {
274
+ const imagePath = await saveImage(BLANK_PNG_BASE64);
275
+ const base = await executeOpenCanvas();
276
+ return { ...base, data: { imageData: imagePath, prompt: "" } };
277
+ }),
268
278
  );
269
279
 
270
280
  // present3d — 3D visualization
@@ -7,7 +7,7 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
7
7
  import { EVENT_TYPES } from "../../../src/types/events.js";
8
8
  import { roleExists, deleteRole, saveRole } from "../../utils/files/roles-io.js";
9
9
 
10
- const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((r) => r.id));
10
+ const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((role) => role.id));
11
11
 
12
12
  const router = Router();
13
13
 
@@ -98,7 +98,7 @@ function saveRoleResult(input: ManageRolesInput, sessionId: string): Record<stri
98
98
  const pluginsToSave = role.availablePlugins ?? [];
99
99
  const roleToSave = {
100
100
  ...role,
101
- availablePlugins: pluginsToSave.filter((p) => p !== "switchRole"),
101
+ availablePlugins: pluginsToSave.filter((plugin) => plugin !== "switchRole"),
102
102
  };
103
103
 
104
104
  saveRole(role.id, roleToSave);
@@ -9,6 +9,8 @@ import { saveUserTasks } from "../../utils/files/user-tasks-io.js";
9
9
  import { startChat } from "./agent.js";
10
10
  import { log } from "../../system/logger/index.js";
11
11
  import { SCHEDULER_ACTIONS, TASK_ACTIONS } from "../../../src/config/schedulerActions.js";
12
+ import { badRequest, notFound, serverError } from "../../utils/httpError.js";
13
+ import { errorMessage } from "../../utils/errors.js";
12
14
 
13
15
  const router = Router();
14
16
 
@@ -77,7 +79,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
77
79
  if (action === SCHEDULER_ACTIONS.createTask) {
78
80
  const result = validateAndCreate(input);
79
81
  if (result.kind === "error") {
80
- res.status(400).json({ error: result.error });
82
+ badRequest(res, result.error);
81
83
  return;
82
84
  }
83
85
  const tasks = loadUserTasks();
@@ -93,11 +95,11 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
93
95
  }
94
96
 
95
97
  if (action === SCHEDULER_ACTIONS.deleteTask) {
96
- const id = typeof input.id === "string" ? input.id : "";
98
+ const taskId = typeof input.id === "string" ? input.id : "";
97
99
  const tasks = loadUserTasks();
98
- const idx = tasks.findIndex((t) => t.id === id);
100
+ const idx = tasks.findIndex((task) => task.id === taskId);
99
101
  if (idx === -1) {
100
- res.status(404).json({ error: `task not found: ${id}` });
102
+ notFound(res, `task not found: ${taskId}`);
101
103
  return;
102
104
  }
103
105
  const name = tasks[idx].name;
@@ -107,17 +109,17 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
107
109
  res.json({
108
110
  uuid: crypto.randomUUID(),
109
111
  message: `Task "${name}" deleted.`,
110
- data: { deleted: id },
112
+ data: { deleted: taskId },
111
113
  });
112
114
  return;
113
115
  }
114
116
 
115
117
  if (action === SCHEDULER_ACTIONS.runTask) {
116
- const id = typeof input.id === "string" ? input.id : "";
118
+ const taskId = typeof input.id === "string" ? input.id : "";
117
119
  const tasks = loadUserTasks();
118
- const task = tasks.find((t) => t.id === id);
120
+ const task = tasks.find((candidate) => candidate.id === taskId);
119
121
  if (!task) {
120
- res.status(404).json({ error: `task not found: ${id}` });
122
+ notFound(res, `task not found: ${taskId}`);
121
123
  return;
122
124
  }
123
125
  const chatSessionId = crypto.randomUUID();
@@ -138,15 +140,15 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
138
140
  res.json({
139
141
  uuid: crypto.randomUUID(),
140
142
  message: `Task "${task.name}" triggered.`,
141
- data: { triggered: id, chatSessionId },
143
+ data: { triggered: taskId, chatSessionId },
142
144
  });
143
145
  return;
144
146
  }
145
147
 
146
- res.status(400).json({ error: `unknown task action: ${action}` });
148
+ badRequest(res, `unknown task action: ${action}`);
147
149
  } catch (err) {
148
- log.error("scheduler", "task action failed", { error: String(err) });
149
- res.status(500).json({ error: "Internal server error" });
150
+ log.error("scheduler", "task action failed", { error: errorMessage(err) });
151
+ serverError(res, "Internal server error");
150
152
  }
151
153
  }
152
154
 
@@ -29,14 +29,14 @@ export type SchedulerActionResult =
29
29
  };
30
30
 
31
31
  export function sortItems(items: ScheduledItem[]): ScheduledItem[] {
32
- return [...items].sort((a, b) => {
33
- const aDate = typeof a.props.date === "string" ? a.props.date : null;
34
- const bDate = typeof b.props.date === "string" ? b.props.date : null;
35
- const aTime = typeof a.props.time === "string" ? a.props.time : "00:00";
36
- const bTime = typeof b.props.time === "string" ? b.props.time : "00:00";
37
- const aKey = aDate ? `0_${aDate}_${aTime}` : `1_${a.createdAt}`;
38
- const bKey = bDate ? `0_${bDate}_${bTime}` : `1_${b.createdAt}`;
39
- return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
32
+ return [...items].sort((left, right) => {
33
+ const leftDate = typeof left.props.date === "string" ? left.props.date : null;
34
+ const rightDate = typeof right.props.date === "string" ? right.props.date : null;
35
+ const leftTime = typeof left.props.time === "string" ? left.props.time : "00:00";
36
+ const rightTime = typeof right.props.time === "string" ? right.props.time : "00:00";
37
+ const leftKey = leftDate ? `0_${leftDate}_${leftTime}` : `1_${left.createdAt}`;
38
+ const rightKey = rightDate ? `0_${rightDate}_${rightTime}` : `1_${right.createdAt}`;
39
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
40
40
  });
41
41
  }
42
42
 
@@ -84,11 +84,11 @@ export function handleDelete(items: ScheduledItem[], input: SchedulerActionInput
84
84
 
85
85
  function applyPropPatch(current: ScheduledItem["props"], patch: Record<string, string | number | boolean | null>): ScheduledItem["props"] {
86
86
  const next: ScheduledItem["props"] = { ...current };
87
- for (const [k, v] of Object.entries(patch)) {
88
- if (v === null) {
89
- delete next[k];
87
+ for (const [key, value] of Object.entries(patch)) {
88
+ if (value === null) {
89
+ delete next[key];
90
90
  } else {
91
- next[k] = v;
91
+ next[key] = value;
92
92
  }
93
93
  }
94
94
  return next;
@@ -15,6 +15,7 @@ import { SESSION_ORIGINS } from "../../../src/types/session.js";
15
15
  import { loadUserTasks, validateAndCreate, applyUpdate, withUserTaskLock } from "../../workspace/skills/user-tasks.js";
16
16
  import { badRequest, notFound, serverError } from "../../utils/httpError.js";
17
17
  import { errorMessage } from "../../utils/errors.js";
18
+ import { getOptionalStringQuery } from "../../utils/request.js";
18
19
  import { log } from "../../system/logger/index.js";
19
20
  import { startChat } from "./agent.js";
20
21
 
@@ -29,7 +30,7 @@ router.get(API_ROUTES.scheduler.tasks, (_req: Request, res: Response) => {
29
30
  // have no origin field of their own.
30
31
  const systemTasks = getSchedulerTasks();
31
32
  const userTasks = loadUserTasks();
32
- const all = [...systemTasks.map((t) => ({ ...t, origin: "system" as const })), ...userTasks.map((t) => ({ ...t, origin: "user" as const }))];
33
+ const all = [...systemTasks.map((task) => ({ ...task, origin: "system" as const })), ...userTasks.map((task) => ({ ...task, origin: "user" as const }))];
33
34
  res.json({ tasks: all });
34
35
  });
35
36
 
@@ -58,14 +59,14 @@ router.post(API_ROUTES.scheduler.tasks, async (req: Request, res: Response) => {
58
59
  // ── Update user task ────────────────────────────────────────────
59
60
 
60
61
  router.put(API_ROUTES.scheduler.task, async (req: Request<{ id: string }>, res: Response) => {
61
- const { id } = req.params;
62
+ const { id: taskId } = req.params;
62
63
  try {
63
64
  const updated = await withUserTaskLock(async (tasks) => {
64
- const result = applyUpdate(tasks, id, req.body);
65
+ const result = applyUpdate(tasks, taskId, req.body);
65
66
  if (result.kind === "error") {
66
67
  throw new Error(result.error);
67
68
  }
68
- const task = result.tasks.find((t) => t.id === id);
69
+ const task = result.tasks.find((taskItem) => taskItem.id === taskId);
69
70
  return { tasks: result.tasks, result: task };
70
71
  });
71
72
  res.json({ task: updated });
@@ -83,15 +84,15 @@ router.put(API_ROUTES.scheduler.task, async (req: Request<{ id: string }>, res:
83
84
  // ── Delete user task ────────────────────────────────────────────
84
85
 
85
86
  router.delete(API_ROUTES.scheduler.task, async (req: Request<{ id: string }>, res: Response) => {
86
- const { id } = req.params;
87
+ const { id: taskId } = req.params;
87
88
  try {
88
89
  await withUserTaskLock(async (tasks) => {
89
- const idx = tasks.findIndex((t) => t.id === id);
90
- if (idx === -1) throw new Error(`task not found: ${id}`);
91
- const next = tasks.filter((t) => t.id !== id);
90
+ const index = tasks.findIndex((task) => task.id === taskId);
91
+ if (index === -1) throw new Error(`task not found: ${taskId}`);
92
+ const next = tasks.filter((task) => task.id !== taskId);
92
93
  return { tasks: next, result: undefined };
93
94
  });
94
- res.json({ deleted: id });
95
+ res.json({ deleted: taskId });
95
96
  } catch (err) {
96
97
  const msg = errorMessage(err);
97
98
  if (msg.startsWith("task not found")) {
@@ -106,10 +107,10 @@ router.delete(API_ROUTES.scheduler.task, async (req: Request<{ id: string }>, re
106
107
  // ── Manual trigger ──────────────────────────────────────────────
107
108
 
108
109
  router.post(API_ROUTES.scheduler.taskRun, async (req: Request<{ id: string }>, res: Response) => {
109
- const { id } = req.params;
110
+ const { id: taskId } = req.params;
110
111
  // Check user tasks first
111
112
  const userTasks = loadUserTasks();
112
- const userTask = userTasks.find((t) => t.id === id);
113
+ const userTask = userTasks.find((task) => task.id === taskId);
113
114
  if (userTask) {
114
115
  const chatSessionId = crypto.randomUUID();
115
116
  log.info("scheduler-tasks", "manual run (user task)", {
@@ -126,14 +127,14 @@ router.post(API_ROUTES.scheduler.taskRun, async (req: Request<{ id: string }>, r
126
127
  error: String(err),
127
128
  });
128
129
  });
129
- res.json({ triggered: id, chatSessionId });
130
+ res.json({ triggered: taskId, chatSessionId });
130
131
  return;
131
132
  }
132
133
  // Not a user task — check system/skill tasks
133
134
  const systemTasks = getSchedulerTasks();
134
- const found = systemTasks.find((t) => t.id === id);
135
+ const found = systemTasks.find((task) => task.id === taskId);
135
136
  if (!found) {
136
- notFound(res, `task not found: ${id}`);
137
+ notFound(res, `task not found: ${taskId}`);
137
138
  return;
138
139
  }
139
140
  // System tasks don't have a prompt to startChat with — return 400
@@ -150,11 +151,12 @@ interface LogQuery {
150
151
 
151
152
  router.get(API_ROUTES.scheduler.logs, async (req: Request<object, unknown, object, LogQuery>, res: Response<{ logs: TaskLogEntry[] }>) => {
152
153
  const MAX_LIMIT = 500;
153
- const rawLimit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : undefined;
154
+ const rawLimitStr = getOptionalStringQuery(req, "limit");
155
+ const rawLimit = rawLimitStr ? parseInt(rawLimitStr, 10) : undefined;
154
156
  const limit = Number.isFinite(rawLimit) && rawLimit! > 0 ? Math.min(rawLimit!, MAX_LIMIT) : undefined;
155
157
  const logs = await getSchedulerLogs({
156
- since: typeof req.query.since === "string" ? req.query.since : undefined,
157
- taskId: typeof req.query.taskId === "string" ? req.query.taskId : undefined,
158
+ since: getOptionalStringQuery(req, "since"),
159
+ taskId: getOptionalStringQuery(req, "taskId"),
158
160
  limit,
159
161
  });
160
162
  res.json({ logs });