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
@@ -15,24 +15,24 @@
15
15
  // the workspace scale (~hundreds of dirs) this is negligible. If
16
16
  // profiling shows otherwise, cache the parsed ignore instances.
17
17
 
18
- import fs from "fs";
18
+ import { readFileSync } from "fs";
19
19
  import path from "path";
20
20
  import ignore, { type Ignore } from "ignore";
21
21
 
22
22
  export class GitignoreFilter {
23
- private ig: Ignore;
23
+ private rules: Ignore;
24
24
 
25
25
  constructor(rules?: string) {
26
- this.ig = ignore();
26
+ this.rules = ignore();
27
27
  if (rules) {
28
- this.ig.add(rules);
28
+ this.rules.add(rules);
29
29
  }
30
30
  }
31
31
 
32
32
  /** Test whether a workspace-relative path should be hidden. */
33
33
  ignores(relPath: string): boolean {
34
34
  if (!relPath) return false;
35
- return this.ig.ignores(relPath);
35
+ return this.rules.ignores(relPath);
36
36
  }
37
37
 
38
38
  /** Create a child filter that inherits this filter's rules and
@@ -40,12 +40,12 @@ export class GitignoreFilter {
40
40
  childForDir(dirAbsPath: string): GitignoreFilter {
41
41
  const child = new GitignoreFilter();
42
42
  // Inherit parent rules
43
- child.ig = ignore().add(this.ig);
43
+ child.rules = ignore().add(this.rules);
44
44
  // Add local .gitignore if present
45
45
  const gitignorePath = path.join(dirAbsPath, ".gitignore");
46
46
  try {
47
- const content = fs.readFileSync(gitignorePath, "utf-8");
48
- child.ig.add(content);
47
+ const content = readFileSync(gitignorePath, "utf-8");
48
+ child.rules.add(content);
49
49
  } catch {
50
50
  // No .gitignore in this directory — just inherit parent
51
51
  }
@@ -61,7 +61,7 @@ export class GitignoreFilter {
61
61
  export function createRootFilter(workspaceRoot: string): GitignoreFilter {
62
62
  const gitignorePath = path.join(workspaceRoot, ".gitignore");
63
63
  try {
64
- const content = fs.readFileSync(gitignorePath, "utf-8");
64
+ const content = readFileSync(gitignorePath, "utf-8");
65
65
  return new GitignoreFilter(content);
66
66
  } catch {
67
67
  return new GitignoreFilter();
@@ -1,16 +1,48 @@
1
- // Unique-ID generation for persisted records. Previously duplicated
2
- // in schedulerHandlers, todosHandlers, and todosItemsHandlers
3
- // consolidated here as part of the server/utils grouping (#350 CLAUDE.md).
1
+ // Unique-ID generation helpers. Three layers, one primitive
2
+ // (`crypto.randomUUID()`). See issue #723 for the design rationale.
4
3
 
5
- import { randomBytes } from "crypto";
4
+ import { randomUUID } from "crypto";
5
+
6
+ // 16-char hex slug length for `shortId()`. 64 bits of entropy —
7
+ // ~10^9 generations before a 1% collision rate — which is plenty
8
+ // for filename suffixes.
9
+ const SHORT_ID_HEX_LEN = 16;
10
+ // 6 hex chars for `makeId()`'s random tail — the timestamp already
11
+ // carries most of the uniqueness, so 24 bits of extra entropy is
12
+ // enough to disambiguate IDs generated in the same millisecond.
13
+ const MAKE_ID_HEX_LEN = 6;
14
+
15
+ /**
16
+ * Full UUID v4 (36 chars, hyphenated).
17
+ *
18
+ * Use when the id is globally unique across the app lifetime and
19
+ * round-trips through URLs, jsonl files, or external systems that
20
+ * already expect the v4 shape (e.g. `chatSessionId`, scheduler
21
+ * `task.id`, notification ids).
22
+ */
23
+ export function makeUuid(): string {
24
+ return randomUUID();
25
+ }
26
+
27
+ /**
28
+ * 16-char hex slug derived from a UUID v4.
29
+ *
30
+ * Use when a short, opaque identifier is sufficient — e.g. image
31
+ * and spreadsheet file-name suffixes. Not suitable for IDs that
32
+ * round-trip through systems expecting UUID v4 formatting.
33
+ */
34
+ export function shortId(): string {
35
+ return randomUUID().replace(/-/g, "").slice(0, SHORT_ID_HEX_LEN);
36
+ }
6
37
 
7
38
  /**
8
- * Generate a short, unique, human-scannable ID.
39
+ * Domain-prefixed, human-scannable ID.
9
40
  *
10
41
  * Format: `<prefix>_<epochMs>_<6 random hex chars>`. The prefix
11
- * is required so IDs from different domains (todo, scheduler, column)
12
- * are visually distinguishable in logs and JSON files.
42
+ * makes IDs from different domains (todo, scheduler, column)
43
+ * visually distinguishable in logs and JSON files.
13
44
  */
14
45
  export function makeId(prefix: string): string {
15
- return `${prefix}_${Date.now()}_${randomBytes(3).toString("hex")}`;
46
+ const randomHex = randomUUID().replace(/-/g, "").slice(0, MAKE_ID_HEX_LEN);
47
+ return `${prefix}_${Date.now()}_${randomHex}`;
16
48
  }
@@ -44,22 +44,22 @@ export function findBalancedBraceBlock(raw: string): string | null {
44
44
  let inString = false;
45
45
  let escape = false;
46
46
  for (let i = start; i < raw.length; i++) {
47
- const ch = raw[i];
47
+ const char = raw[i];
48
48
  if (escape) {
49
49
  escape = false;
50
50
  continue;
51
51
  }
52
- if (ch === "\\") {
52
+ if (char === "\\") {
53
53
  escape = true;
54
54
  continue;
55
55
  }
56
- if (ch === '"') {
56
+ if (char === '"') {
57
57
  inString = !inString;
58
58
  continue;
59
59
  }
60
60
  if (inString) continue;
61
- if (ch === "{") depth++;
62
- if (ch === "}" && --depth === 0) return raw.slice(start, i + 1);
61
+ if (char === "{") depth++;
62
+ if (char === "}" && --depth === 0) return raw.slice(start, i + 1);
63
63
  }
64
64
  return null;
65
65
  }
@@ -1,4 +1,5 @@
1
1
  import { log } from "../system/logger/index.js";
2
+ import { errorMessage } from "./errors.js";
2
3
 
3
4
  /**
4
5
  * Build a `.catch` handler for a fire-and-forget background job that
@@ -8,15 +9,23 @@ import { log } from "../system/logger/index.js";
8
9
  *
9
10
  * Usage:
10
11
  *
12
+ * // Default message — good for generic background scans.
11
13
  * maybeRunJournal({ ... }).catch(logBackgroundError("journal"));
12
14
  *
15
+ * // Custom message — pass context when the failure mode is
16
+ * // worth surfacing distinctly (e.g. "failed to register
17
+ * // scheduled skills" vs other `skills` warnings).
18
+ * registerScheduledSkills(...).catch(
19
+ * logBackgroundError("skills", "failed to register scheduled skills"),
20
+ * );
21
+ *
13
22
  * The handler never rethrows — the caller's promise chain is
14
23
  * terminated cleanly so nothing propagates into the request path.
15
24
  */
16
- export function logBackgroundError(prefix: string): (err: unknown) => void {
25
+ export function logBackgroundError(prefix: string, message = "unexpected error in background"): (err: unknown) => void {
17
26
  return (err) => {
18
- log.warn(prefix, "unexpected error in background", {
19
- error: String(err),
27
+ log.warn(prefix, message, {
28
+ error: errorMessage(err),
20
29
  });
21
30
  };
22
31
  }
@@ -0,0 +1,24 @@
1
+ // Truncating preview for log payloads.
2
+ //
3
+ // Logs of user-supplied freeform text (prompts, wiki bodies, search
4
+ // queries) must never include the full body — partly to keep log
5
+ // files small, partly to limit accidental PII / secret leakage when
6
+ // logs are shared during debugging. 120 chars + an ellipsis is the
7
+ // shape the image-generation logging (PR #780) settled on; this
8
+ // module exists so other routes use the same constant rather than
9
+ // hand-rolling their own cap.
10
+ //
11
+ // Usage:
12
+ // log.info("wiki", "page: start", { pageNamePreview: previewSnippet(pageName) });
13
+ //
14
+ // `null` / `undefined` → empty string, so the logger never has to
15
+ // guard against missing input.
16
+
17
+ const PREVIEW_CHAR_LIMIT = 120;
18
+ const ELLIPSIS = "…";
19
+
20
+ export function previewSnippet(input: string | null | undefined): string {
21
+ if (!input) return "";
22
+ if (input.length <= PREVIEW_CHAR_LIMIT) return input;
23
+ return `${input.slice(0, PREVIEW_CHAR_LIMIT)}${ELLIPSIS}`;
24
+ }
@@ -68,15 +68,15 @@ export function rewriteMarkdownLinks(input: string, rewrite: (href: string) => s
68
68
  * Split a trailing `#fragment` or `?query` off a path so the caller
69
69
  * can rewrite the path portion and concatenate the suffix back.
70
70
  */
71
- export function splitFragmentAndQuery(s: string): {
71
+ export function splitFragmentAndQuery(href: string): {
72
72
  pathPart: string;
73
73
  suffix: string;
74
74
  } {
75
- const hashIdx = s.indexOf("#");
76
- const queryIdx = s.indexOf("?");
75
+ const hashIdx = href.indexOf("#");
76
+ const queryIdx = href.indexOf("?");
77
77
  let cut = -1;
78
78
  if (hashIdx !== -1) cut = hashIdx;
79
79
  if (queryIdx !== -1 && (cut === -1 || queryIdx < cut)) cut = queryIdx;
80
- if (cut === -1) return { pathPart: s, suffix: "" };
81
- return { pathPart: s.slice(0, cut), suffix: s.slice(cut) };
80
+ if (cut === -1) return { pathPart: href, suffix: "" };
81
+ return { pathPart: href.slice(0, cut), suffix: href.slice(cut) };
82
82
  }
@@ -0,0 +1,6 @@
1
+ // Type declarations for port.mjs. See port.mjs for rationale on why
2
+ // the shared helper lives in plain JS.
3
+
4
+ export const MAX_PORT_PROBES: number;
5
+ export function isPortFree(port: number): Promise<boolean>;
6
+ export function findAvailablePort(start: number): Promise<number | null>;
@@ -0,0 +1,48 @@
1
+ // Shared port-resolution helpers for the dev server and the npm
2
+ // launcher (`packages/mulmoclaude/bin/mulmoclaude.js`).
3
+ //
4
+ // Kept as plain `.mjs` (no TypeScript) because the launcher runs
5
+ // directly under Node — it boots BEFORE tsx is wired up, so it can't
6
+ // import from a `.ts` file. The server-side TypeScript imports this
7
+ // through Node's ESM resolution; `moduleResolution: "bundler"` + the
8
+ // sibling `port.d.mts` declarations give us the type coverage without
9
+ // turning the whole `server/` tree into mixed JS/TS.
10
+
11
+ import net from "node:net";
12
+
13
+ // Scan cap: 20 slots is enough to step around the occasional stale
14
+ // server on `localhost` without spinning forever on a pathologically
15
+ // saturated machine.
16
+ export const MAX_PORT_PROBES = 20;
17
+
18
+ /**
19
+ * Returns true iff binding `127.0.0.1:port` would succeed right now.
20
+ * @param {number} port
21
+ * @returns {Promise<boolean>}
22
+ */
23
+ export function isPortFree(port) {
24
+ return new Promise((resolve) => {
25
+ const server = net.createServer();
26
+ server.once("error", () => resolve(false));
27
+ server.once("listening", () => {
28
+ server.close(() => resolve(true));
29
+ });
30
+ // Probe the same interface we'll actually bind to so a port
31
+ // held by a different process on a different interface doesn't
32
+ // give us a false "free" reading.
33
+ server.listen(port, "127.0.0.1");
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Walk forward from `start` until we find a free port. Returns `null`
39
+ * if every slot in `[start, start + MAX_PORT_PROBES)` is busy.
40
+ * @param {number} start
41
+ * @returns {Promise<number | null>}
42
+ */
43
+ export async function findAvailablePort(start) {
44
+ for (let candidate = start; candidate < start + MAX_PORT_PROBES; candidate++) {
45
+ if (await isPortFree(candidate)) return candidate;
46
+ }
47
+ return null;
48
+ }
@@ -0,0 +1,32 @@
1
+ // Fingerprint user-controlled prompt text for structured logs without
2
+ // leaking the prompt itself.
3
+ //
4
+ // Image / text prompts are user input and routinely include pasted
5
+ // URLs, emails, or even credentials. Persisting any prefix of the
6
+ // raw prompt into structured log files (which we keep on disk and
7
+ // rotate) is a PII / secret leak waiting to happen.
8
+ //
9
+ // The fingerprint pairs `length` with a 12-hex-char prefix of the
10
+ // SHA-256 hash. 48 bits of entropy is enough to correlate retries
11
+ // and duplicate-request bursts within a session — collision risk
12
+ // at session-scale request volumes is negligible — without exposing
13
+ // any content.
14
+ //
15
+ // Use this in every new log call that would otherwise want to print
16
+ // a `promptPreview`. Existing call sites should migrate over time.
17
+
18
+ import { createHash } from "node:crypto";
19
+
20
+ const PROMPT_SHA256_PREFIX_CHARS = 12;
21
+
22
+ export interface PromptMeta {
23
+ length: number;
24
+ sha256: string;
25
+ }
26
+
27
+ export function promptMeta(prompt: string): PromptMeta {
28
+ return {
29
+ length: prompt.length,
30
+ sha256: createHash("sha256").update(prompt).digest("hex").slice(0, PROMPT_SHA256_PREFIX_CHARS),
31
+ };
32
+ }
@@ -3,11 +3,17 @@
3
3
  // Centralizes patterns that were duplicated across route handlers
4
4
  // (3+ different ways to read `req.query.session`).
5
5
 
6
- // Use a minimal interface so the helpers work with any Express
7
- // Request generic (Request<object, ...>, Request<Params, ...>, etc.)
8
- // without type incompatibility.
6
+ // `query: object` so the helpers work with any Express Request
7
+ // generic — `Request<Params, ResBody, ReqBody, Query>`. A narrow
8
+ // `Query` generic like `{ path?: string }` isn't assignable to
9
+ // `Record<string, unknown>` (no index signature), so we widen to
10
+ // `object` and cast internally when reading a key.
9
11
  interface HasQuery {
10
- query: Record<string, unknown>;
12
+ query: object;
13
+ }
14
+
15
+ function readQueryKey(queryObj: object, key: string): unknown {
16
+ return (queryObj as Record<string, unknown>)[key];
11
17
  }
12
18
 
13
19
  /**
@@ -15,7 +21,7 @@ interface HasQuery {
15
21
  * Returns the string value, or "" if missing/non-string.
16
22
  */
17
23
  export function getSessionQuery(req: HasQuery): string {
18
- const raw = req.query.session;
24
+ const raw = readQueryKey(req.query, "session");
19
25
  return typeof raw === "string" ? raw : "";
20
26
  }
21
27
 
@@ -24,6 +30,6 @@ export function getSessionQuery(req: HasQuery): string {
24
30
  * Returns the string value, or undefined if missing/non-string.
25
31
  */
26
32
  export function getOptionalStringQuery(req: HasQuery, key: string): string | undefined {
27
- const raw = req.query[key];
33
+ const raw = readQueryKey(req.query, key);
28
34
  return typeof raw === "string" ? raw : undefined;
29
35
  }
@@ -5,6 +5,19 @@ import { createHash } from "crypto";
5
5
  // collisions are effectively impossible for any realistic workspace.
6
6
  const NON_ASCII_HASH_LEN = 16;
7
7
 
8
+ // Max slug length used by both `slugify` (output cap) and `isValidSlug`
9
+ // (acceptance cap). 120 leaves plenty of room for filename slugs while
10
+ // staying well under filesystem path limits and URL-segment conventions.
11
+ // Bumped from 64 alongside the slug-rule unification (#732) so journal /
12
+ // todo / wiki / files can all share one rule without truncating their
13
+ // previously-longer inputs.
14
+ //
15
+ // Exported so callers that compose a base slug with their own suffix
16
+ // (e.g. `${base}-2` collision avoidance) can stay inside the same cap
17
+ // — necessary because a 120-char base + "-2" would otherwise produce a
18
+ // 122-char id that fails `isValidSlug`.
19
+ export const DEFAULT_MAX_LENGTH = 120;
20
+
8
21
  // eslint-disable-next-line no-control-regex
9
22
  const NON_ASCII_RE = /[^\x00-\x7F]/;
10
23
 
@@ -18,18 +31,18 @@ export function hashSlug(input: string, length: number = NON_ASCII_HASH_LEN): st
18
31
  return createHash("sha256").update(input, "utf-8").digest("base64url").slice(0, length);
19
32
  }
20
33
 
21
- // Validates a slug: lowercase alphanumeric + hyphens, 1–64 chars,
22
- // no leading/trailing hyphen, no consecutive hyphens. Previously
34
+ // Validates a slug: lowercase alphanumeric + hyphens, 1–DEFAULT_MAX_LENGTH
35
+ // chars, no leading/trailing hyphen, no consecutive hyphens. Previously
23
36
  // duplicated in sources/paths.ts and skills/paths.ts.
24
37
  export function isValidSlug(slug: string): boolean {
25
38
  if (typeof slug !== "string") return false;
26
- if (slug.length === 0 || slug.length > 64) return false;
39
+ if (slug.length === 0 || slug.length > DEFAULT_MAX_LENGTH) return false;
27
40
  if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug)) return false;
28
41
  if (slug.includes("--")) return false;
29
42
  return true;
30
43
  }
31
44
 
32
- export function slugify(title: string, defaultSlug = "page", maxLength = 60): string {
45
+ export function slugify(title: string, defaultSlug = "page", maxLength = DEFAULT_MAX_LENGTH): string {
33
46
  const asciiSlug = title
34
47
  .toLowerCase()
35
48
  .replace(/[^a-z0-9]+/g, "-")
@@ -48,3 +61,51 @@ export function slugify(title: string, defaultSlug = "page", maxLength = 60): st
48
61
  }
49
62
  return hash;
50
63
  }
64
+
65
+ // Disambiguate `base` against `existingIds` by appending `-2`, `-3`, …
66
+ // until a free id is found. Truncates `base` (stripping any trailing
67
+ // hyphen so the join never produces `--`) so the composite stays
68
+ // within `DEFAULT_MAX_LENGTH` — required because a 120-char base + "-2"
69
+ // would otherwise produce a 122-char id that fails `isValidSlug`.
70
+ //
71
+ // Shared between server (`todosColumnsHandlers#uniqueId`) and the
72
+ // Playwright mock (`e2e/tests/todo-columns.spec.ts`) so the e2e
73
+ // dispatcher mirrors server behavior at the truncation boundary.
74
+ //
75
+ // Precondition: `base` must already be a canonical slug — typically
76
+ // the output of `slugify`. Concretely: lowercase ASCII alnum +
77
+ // hyphens, no leading/trailing hyphen, no `--`, length within
78
+ // `DEFAULT_MAX_LENGTH`. Inputs outside that grammar (e.g. uppercase,
79
+ // punctuation, leading hyphen) get NO defensive treatment — if they
80
+ // collide they will be disambiguated to an equally-invalid suffix
81
+ // form. The helper only special-cases empty/all-hyphen bases (where
82
+ // no valid disambiguation exists at all) and returns them
83
+ // unchanged so it never *fabricates* a `-2`-style invalid slug from
84
+ // nothing. Codex iter-4/5 #732.
85
+ export function disambiguateSlug(base: string, existingIds: ReadonlySet<string>): string {
86
+ if (!hasNonHyphenChar(base)) return base;
87
+ if (!existingIds.has(base)) return base;
88
+ const compose = (suffix: number): string => {
89
+ const tail = `-${suffix}`;
90
+ const room = DEFAULT_MAX_LENGTH - tail.length;
91
+ // Pick the cut: full base when it already fits, room-truncated
92
+ // otherwise. Then strip any trailing hyphen at that cut so the
93
+ // join never yields `--` (which `isValidSlug` rejects). Manual
94
+ // loop instead of a regex — `sonarjs/slow-regex` flags `-+$`
95
+ // even on this bounded input.
96
+ const cut = base.length <= room ? base.length : room;
97
+ let end = cut;
98
+ while (end > 0 && base[end - 1] === "-") end--;
99
+ return `${base.slice(0, end)}${tail}`;
100
+ };
101
+ let suffix = 2;
102
+ while (existingIds.has(compose(suffix))) suffix++;
103
+ return compose(suffix);
104
+ }
105
+
106
+ function hasNonHyphenChar(input: string): boolean {
107
+ for (let i = 0; i < input.length; i++) {
108
+ if (input[i] !== "-") return true;
109
+ }
110
+ return false;
111
+ }
@@ -28,7 +28,7 @@ export function extractClaudeErrorMessage(stdout: string): string | null {
28
28
  if (!isRecord(parsed)) return null;
29
29
  if (parsed.is_error !== true) return null;
30
30
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
31
- const joined = parsed.errors.filter((e): e is string => typeof e === "string").join("; ");
31
+ const joined = parsed.errors.filter((err): err is string => typeof err === "string").join("; ");
32
32
  if (joined.length > 0) return joined;
33
33
  }
34
34
  const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
@@ -23,12 +23,12 @@ export function isNonEmptyString(value: unknown): value is string {
23
23
  /** Record whose values are all strings. */
24
24
  export function isStringRecord(value: unknown): value is Record<string, string> {
25
25
  if (!isRecord(value)) return false;
26
- return Object.values(value).every((v) => typeof v === "string");
26
+ return Object.values(value).every((val) => typeof val === "string");
27
27
  }
28
28
 
29
29
  /** String array (every element is a string). */
30
30
  export function isStringArray(value: unknown): value is string[] {
31
- return Array.isArray(value) && value.every((v) => typeof v === "string");
31
+ return Array.isArray(value) && value.every((val) => typeof val === "string");
32
32
  }
33
33
 
34
34
  /** Error-like object with a `code` property (e.g. Node.js fs errors). */
@@ -14,7 +14,7 @@
14
14
  // point at a `mkdtempSync` directory.
15
15
 
16
16
  import { workspacePath as defaultWorkspacePath } from "../workspace.js";
17
- import { ClaudeCliNotFoundError } from "../journal/archivist.js";
17
+ import { ClaudeCliNotFoundError } from "../journal/archivist-cli.js";
18
18
  import { indexSession, listSessionIds, type IndexerDeps } from "./indexer.js";
19
19
  import { log } from "../../system/logger/index.js";
20
20
 
@@ -17,7 +17,7 @@ import { EVENT_TYPES } from "../../../src/types/events.js";
17
17
  import { readFile } from "node:fs/promises";
18
18
  import { formatSpawnFailure } from "../../utils/spawn.js";
19
19
  import { tmpdir } from "node:os";
20
- import { ClaudeCliNotFoundError } from "../journal/archivist.js";
20
+ import { ClaudeCliNotFoundError } from "../journal/archivist-cli.js";
21
21
  import { errorMessage } from "../../utils/errors.js";
22
22
  import type { SummaryResult } from "./types.js";
23
23
  import { ONE_MINUTE_MS } from "../../utils/time.js";
@@ -152,10 +152,10 @@ export function validateSummaryResult(obj: unknown): SummaryResult {
152
152
  if (!isRecord(obj)) {
153
153
  throw new Error("[chat-index] summary result is not an object");
154
154
  }
155
- const o = obj as Record<string, unknown>;
156
- const title = typeof o.title === "string" ? o.title : "";
157
- const summary = typeof o.summary === "string" ? o.summary : "";
158
- const keywords = Array.isArray(o.keywords) ? o.keywords.filter((k): k is string => typeof k === "string") : [];
155
+ const record = obj as Record<string, unknown>;
156
+ const title = typeof record.title === "string" ? record.title : "";
157
+ const summary = typeof record.summary === "string" ? record.summary : "";
158
+ const keywords = Array.isArray(record.keywords) ? record.keywords.filter((keyword): keyword is string => typeof keyword === "string") : [];
159
159
  return { title, summary, keywords };
160
160
  }
161
161
 
@@ -4,7 +4,7 @@
4
4
  // directories under `data/` and `artifacts/` for organizing files.
5
5
  // Claude sees these in the system prompt and routes saves accordingly.
6
6
 
7
- import fs from "fs";
7
+ import { existsSync, mkdirSync, readFileSync } from "fs";
8
8
  import path from "path";
9
9
  import { workspacePath, WORKSPACE_DIRS } from "./paths.js";
10
10
  import { log } from "../system/logger/index.js";
@@ -102,8 +102,8 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
102
102
  const base = root ?? workspacePath;
103
103
  const filePath = path.join(base, CONFIG_FILE);
104
104
  try {
105
- if (!fs.existsSync(filePath)) return [];
106
- const raw = fs.readFileSync(filePath, "utf-8");
105
+ if (!existsSync(filePath)) return [];
106
+ const raw = readFileSync(filePath, "utf-8");
107
107
  const parsed: unknown = JSON.parse(raw);
108
108
  if (!Array.isArray(parsed)) {
109
109
  log.warn("custom-dirs", "workspace-dirs.json is not an array");
@@ -132,7 +132,7 @@ export function loadCustomDirs(root?: string): CustomDirEntry[] {
132
132
  export function saveCustomDirs(entries: readonly CustomDirEntry[], root?: string): void {
133
133
  const base = root ?? workspacePath;
134
134
  const filePath = path.join(base, CONFIG_FILE);
135
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
135
+ mkdirSync(path.dirname(filePath), { recursive: true });
136
136
  writeFileAtomicSync(filePath, JSON.stringify(entries, null, 2));
137
137
  invalidateCache();
138
138
  }
@@ -186,7 +186,7 @@ export function ensureCustomDirs(entries: readonly CustomDirEntry[], root?: stri
186
186
  const base = root ?? workspacePath;
187
187
  for (const entry of entries) {
188
188
  const dirPath = path.join(base, entry.path);
189
- fs.mkdirSync(dirPath, { recursive: true });
189
+ mkdirSync(dirPath, { recursive: true });
190
190
  }
191
191
  }
192
192
 
@@ -0,0 +1,57 @@
1
+ # Gemini API Key
2
+
3
+ MulmoClaude uses Google's Gemini API for image, audio, and video generation. Setting the `GEMINI_API_KEY` environment variable is **technically optional**, but we **strongly recommend** it — a large portion of MulmoClaude's visual and multimedia output depends on it.
4
+
5
+ A single key unlocks all three capabilities (images, TTS audio, video) — you don't need separate credentials for each.
6
+
7
+ ## What the Key Unlocks
8
+
9
+ ### Images
10
+
11
+ - **`generateImage`** — creates images from text prompts. The heart of the **Artist** role.
12
+ - **`editImage`** — transforms or restyles an existing image ("convert to Ghibli style", "remove the background", "add a sunset"). Also **Artist**.
13
+ - **Inline document images** — roles that produce rich documents (**Guide & Planner**, **Tutor**, Recipe Guide, Trip Planner, …) embed generated images directly into the page via `presentDocument`. Without a key, those image slots fall back to italic "🖼️ Image: &lt;prompt&gt;" text markers — the prompt is preserved, but no picture renders.
14
+ - **MulmoScript image beats** — `presentMulmoScript` uses Gemini image models for `imagePrompt` beats. **Storyteller Plus** additionally uses them for consistent-character scenes across a storyboard.
15
+
16
+ ### Audio
17
+
18
+ - **MulmoScript speech** — `presentMulmoScript` synthesizes speaker voices via Gemini TTS (`gemini-2.5-flash-preview-tts`). This is what turns a storyboard into spoken narration, so the **Storyteller** and **Storyteller Plus** roles become near-complete multimedia pieces.
19
+
20
+ ### Video
21
+
22
+ - **MulmoScript movie beats** — beats whose `image.type` is `moviePrompt` are rendered with Google's **Veo** models (`veo-2.0-generate-001`, `veo-3.0-generate-001`, …). Without a key, movie beats can't be produced.
23
+
24
+ ### Bottom line
25
+
26
+ Without a Gemini API key:
27
+
28
+ - The **Artist** role has nothing to generate.
29
+ - **Storyteller** and **Storyteller Plus** still produce a storyboard, but without images or narration.
30
+ - Rich documents from **Guide & Planner**, **Tutor**, and similar roles render as text-only with placeholder markers where images should be.
31
+
32
+ ## How to Get a Key
33
+
34
+ The Gemini API has a **free tier that is sufficient for personal use**. Higher-volume or premium-model use (e.g. Veo video) may require a paid plan.
35
+
36
+ 1. Open [Google AI Studio → API keys](https://aistudio.google.com/apikey) and sign in with a Google account.
37
+ 2. Click **Create API key**. If prompted, select or create a Google Cloud project (any project will do).
38
+ 3. Copy the key — it starts with `AIza…`.
39
+ 4. Open the project's `.env` file in the repository root (copy `.env.example` first if it doesn't exist yet) and add the line:
40
+
41
+ ```
42
+ GEMINI_API_KEY=AIza…your-key…
43
+ ```
44
+
45
+ 5. Restart MulmoClaude so the new environment variable is picked up.
46
+
47
+ ## Verifying It's Active
48
+
49
+ The quickest check: switch to the **Artist** role and ask for _"an image of a red panda"_. If a real image appears in the canvas (instead of an italic text marker or a disabled-role hint), the key is wired up correctly.
50
+
51
+ You can also inspect the server log on startup: messages like `GEMINI_API_KEY not set — image placeholders will render as text markers` indicate the key is missing or misread.
52
+
53
+ ## Security
54
+
55
+ - The key lives in your local `.env` file. MulmoClaude never uploads it to its own servers or to Anthropic — requests go directly from your machine to Google.
56
+ - Treat the key like a password. Anyone who sees it can make billable API calls against your Google account.
57
+ - If you suspect a key has leaked, revoke it from [Google AI Studio → API keys](https://aistudio.google.com/apikey) and generate a new one.
@@ -15,7 +15,6 @@ Under the hood it uses the Claude Code Agent SDK as its LLM core. Claude has ful
15
15
  - **Tutor** — Assesses your knowledge level, then teaches any topic with structured documents and visuals.
16
16
  - **Storyteller** — Crafts illustrated narrative stories as a MulmoScript storyboard.
17
17
  - **Storyteller Plus** — Like Storyteller, with consistent character images across beats.
18
- - **Role Manager** — Create, edit, and delete custom roles.
19
18
  - *(Additional roles may be defined by the user in the workspace.)*
20
19
 
21
20
  ## Key Capabilities
@@ -40,11 +39,13 @@ See [Wiki](helps/wiki.md) for details on how it works.
40
39
  ## Help Pages
41
40
 
42
41
  - [Wiki](helps/wiki.md) — how the personal knowledge wiki works, its folder layout, page format, and operations
42
+ - [Gemini API Key](helps/gemini.md) — why `GEMINI_API_KEY` is strongly recommended (images, audio, video) and how to get one from Google AI Studio
43
43
  - [MulmoScript](helps/mulmoscript.md) — format reference for authoring multimedia stories: beats, image types, speech, audio, and a minimal example
44
44
  - [Business Presentation Template](helps/business.md) — MulmoScript template and rules for business presentations in the Office role
45
45
  - [Spreadsheet](helps/spreadsheet.md) — cell format, formulas, date handling, and format codes for the presentSpreadsheet plugin
46
46
  - [Sandbox](helps/sandbox.md) — how the Docker sandbox isolates the agent, what it can access, and how to disable it
47
47
  - [Telegram Bridge](helps/telegram.md) — how to talk to MulmoClaude from the Telegram app: creating a bot, starting the bridge, allowlisting chat IDs, commands, and troubleshooting
48
+ - [Information Sources](helps/sources.md) — registering RSS feeds / GitHub repos / arXiv queries, the daily-brief pipeline, and where its files live on disk
48
49
 
49
50
  ## Workspace Layout
50
51