mulmoclaude 0.6.1 → 0.6.2

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 (139) hide show
  1. package/bin/mulmoclaude.js +1 -1
  2. package/client/assets/PluginScopedRoot-YjvQq0Nn.js +3 -0
  3. package/client/assets/{html2canvas-CDGcmOD3-BbPeutDg.js → html2canvas-CDGcmOD3-Bkf2uOth.js} +1 -1
  4. package/client/assets/{index-BZdOOa5E.js → index-BwrlMMHr.js} +66 -65
  5. package/client/assets/index-CvvNuegU.css +2 -0
  6. package/client/assets/{index.es-DqtpmBm8-DJdTPdnc.js → index.es-DqtpmBm8-D9mAh_KQ.js} +1 -1
  7. package/client/assets/material-symbols-outlined-BOZVWuR3.woff2 +0 -0
  8. package/client/assets/runtime-protocol-vue-C1To4M3t.js +1 -0
  9. package/client/index.html +7 -6
  10. package/package.json +7 -7
  11. package/server/accounting/eventPublisher.ts +2 -1
  12. package/server/accounting/snapshotCache.ts +2 -1
  13. package/server/agent/backend/claude-code.ts +1 -0
  14. package/server/agent/backend/types.ts +3 -0
  15. package/server/agent/config.ts +25 -2
  16. package/server/agent/index.ts +6 -0
  17. package/server/agent/prompt.ts +37 -24
  18. package/server/api/routes/accounting.ts +31 -24
  19. package/server/api/routes/agent.ts +2 -2
  20. package/server/api/routes/config-refresh.ts +49 -0
  21. package/server/api/routes/config.ts +77 -67
  22. package/server/api/routes/files.ts +41 -17
  23. package/server/api/routes/hookLog.ts +95 -0
  24. package/server/api/routes/news.ts +39 -52
  25. package/server/api/routes/notifier.ts +14 -19
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/presentSvg.ts +107 -0
  28. package/server/api/routes/scheduler.ts +100 -98
  29. package/server/api/routes/schedulerTasks.ts +98 -95
  30. package/server/api/routes/sessions.ts +22 -27
  31. package/server/api/routes/sources.ts +45 -43
  32. package/server/api/routes/wiki/history.ts +6 -15
  33. package/server/api/routes/wiki.ts +73 -276
  34. package/server/events/file-change.ts +3 -2
  35. package/server/events/session-store/index.ts +2 -1
  36. package/server/index.ts +117 -8
  37. package/server/notifier/store.ts +3 -3
  38. package/server/plugins/preset-list.ts +16 -5
  39. package/server/plugins/runtime.ts +2 -2
  40. package/server/system/config.ts +44 -2
  41. package/server/utils/asyncHandler.ts +75 -0
  42. package/server/utils/files/accounting-io.ts +19 -20
  43. package/server/utils/files/journal-io.ts +2 -1
  44. package/server/utils/files/json.ts +8 -1
  45. package/server/utils/files/reference-dirs-io.ts +2 -3
  46. package/server/utils/files/scheduler-overrides-io.ts +2 -3
  47. package/server/utils/files/svg-store.ts +27 -0
  48. package/server/utils/files/user-tasks-io.ts +2 -3
  49. package/server/utils/regex.ts +3 -12
  50. package/server/utils/text.ts +29 -0
  51. package/server/workspace/chat-index/summarizer.ts +5 -3
  52. package/server/workspace/cooking-recipes/migrate.ts +125 -0
  53. package/server/workspace/custom-dirs.ts +2 -2
  54. package/server/workspace/hooks/dispatcher.mjs +300 -0
  55. package/server/workspace/hooks/dispatcher.ts +55 -0
  56. package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
  57. package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
  58. package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
  59. package/server/workspace/hooks/provision.ts +222 -0
  60. package/server/workspace/hooks/shared/sidecar.ts +124 -0
  61. package/server/workspace/hooks/shared/stdin.ts +60 -0
  62. package/server/workspace/hooks/shared/workspace.ts +13 -0
  63. package/server/workspace/journal/dailyPass.ts +1 -6
  64. package/server/workspace/memory/io.ts +1 -34
  65. package/server/workspace/memory/migrate.ts +2 -1
  66. package/server/workspace/memory/snapshot.ts +26 -0
  67. package/server/workspace/memory/topic-io.ts +1 -18
  68. package/server/workspace/paths.ts +10 -0
  69. package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  70. package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
  71. package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
  72. package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
  73. package/server/workspace/skills-preset.ts +2 -1
  74. package/server/workspace/wiki-pages/io.ts +2 -1
  75. package/src/App.vue +51 -3
  76. package/src/components/ChatInput.vue +7 -8
  77. package/src/components/FileContentHeader.vue +1 -6
  78. package/src/components/FileDropOverlay.vue +18 -0
  79. package/src/components/RolesView.vue +14 -5
  80. package/src/components/SettingsMcpTab.vue +15 -10
  81. package/src/components/SettingsModal.vue +116 -130
  82. package/src/components/SettingsModelTab.vue +121 -0
  83. package/src/composables/useContentDisplay.ts +16 -0
  84. package/src/composables/useFileDropZone.ts +148 -0
  85. package/src/composables/useSkillsList.ts +2 -1
  86. package/src/config/apiRoutes.ts +22 -0
  87. package/src/config/roles.ts +78 -48
  88. package/src/config/toolNames.ts +4 -1
  89. package/src/lang/de.ts +36 -1
  90. package/src/lang/en.ts +36 -1
  91. package/src/lang/es.ts +36 -1
  92. package/src/lang/fr.ts +36 -1
  93. package/src/lang/ja.ts +36 -1
  94. package/src/lang/ko.ts +36 -1
  95. package/src/lang/pt-BR.ts +36 -1
  96. package/src/lang/zh.ts +36 -1
  97. package/src/lib/wiki-page/index-parse.ts +221 -0
  98. package/src/lib/wiki-page/link.ts +62 -0
  99. package/src/lib/wiki-page/lint.ts +105 -0
  100. package/src/lib/wiki-page/paths.ts +35 -0
  101. package/src/lib/wiki-page/slug.ts +28 -40
  102. package/src/main.ts +1 -0
  103. package/src/plugins/_generated/metas.ts +2 -0
  104. package/src/plugins/_generated/registrations.ts +2 -0
  105. package/src/plugins/_generated/server-bindings.ts +3 -0
  106. package/src/plugins/accounting/Preview.vue +3 -6
  107. package/src/plugins/accounting/View.vue +2 -1
  108. package/src/plugins/accounting/components/AccountsModal.vue +3 -2
  109. package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
  110. package/src/plugins/accounting/components/JournalList.vue +2 -1
  111. package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
  112. package/src/plugins/accounting/currencies.ts +13 -0
  113. package/src/plugins/manageRoles/View.vue +16 -5
  114. package/src/plugins/photoLocations/View.vue +4 -2
  115. package/src/plugins/presentSVG/Preview.vue +56 -0
  116. package/src/plugins/presentSVG/View.vue +465 -0
  117. package/src/plugins/presentSVG/definition.ts +29 -0
  118. package/src/plugins/presentSVG/index.ts +49 -0
  119. package/src/plugins/presentSVG/meta.ts +14 -0
  120. package/src/plugins/scheduler/View.vue +3 -7
  121. package/src/plugins/skill/View.vue +11 -13
  122. package/src/plugins/wiki/View.vue +1 -1
  123. package/src/plugins/wiki/helpers.ts +23 -5
  124. package/src/plugins/wiki/route.ts +12 -11
  125. package/src/tools/runtimeLoader.ts +75 -9
  126. package/src/utils/format/bytes.ts +41 -0
  127. package/src/utils/format/date.ts +14 -2
  128. package/src/utils/markdown/setup.ts +5 -0
  129. package/src/utils/markdown/workspaceLinkify.ts +73 -0
  130. package/client/assets/index-Bl3vqgA6.css +0 -2
  131. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  132. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
  133. package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
  134. package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
  135. package/server/workspace/wiki-history/provision.ts +0 -181
  136. /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
  137. /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
  138. /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
  139. /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
@@ -4,7 +4,7 @@ import { createRequire } from "node:module";
4
4
  import type { Role } from "../../src/config/roles.js";
5
5
  import { mcpTools, isMcpToolEnabled } from "./mcp-tools/index.js";
6
6
  import { getActiveToolDescriptors } from "./activeTools.js";
7
- import type { McpServerSpec } from "../system/config.js";
7
+ import type { EffortLevel, McpServerSpec } from "../system/config.js";
8
8
  import { getCurrentToken } from "../api/auth/token.js";
9
9
  import type { Attachment } from "@mulmobridge/protocol";
10
10
  import { isImageMime, isNativeAttachmentMime } from "@mulmobridge/client";
@@ -79,6 +79,22 @@ export function prepareUserServers(userServers: Record<string, McpServerSpec>, u
79
79
  if (spec.type === "http") {
80
80
  out[serverId] = prepareUserHttpServer(spec, useDocker);
81
81
  } else {
82
+ // Stay symmetric with `userServerAllowedToolNames`: stdio
83
+ // servers can't run inside the sandbox image (see
84
+ // docs/mcp-sandbox.md for the full rationale — #162 / #1334).
85
+ // Claude CLI 2.1.x silently exits 1 when a stdio MCP fails to
86
+ // start, so passing the spec through here would mask the
87
+ // failure as a generic boot error. Drop + log per skipped
88
+ // entry so an operator scanning the log knows why their MCP
89
+ // didn't load.
90
+ if (useDocker) {
91
+ log.info("mcp", "skipping stdio server in Docker sandbox", {
92
+ serverId,
93
+ transport: "stdio",
94
+ reason: "sandbox image is too minimal to host arbitrary stdio MCP runtimes",
95
+ });
96
+ continue;
97
+ }
82
98
  out[serverId] = prepareUserStdioServer(spec, useDocker, hostWorkspacePath);
83
99
  }
84
100
  }
@@ -211,10 +227,13 @@ export interface CliArgsParams {
211
227
  // Web UI-managed extension of the allowed-tools list. Merged with
212
228
  // BASE_ALLOWED_TOOLS and the mcp__mulmoclaude__ plugin names.
213
229
  extraAllowedTools?: string[];
230
+ // Reasoning effort (#1323). When undefined, the flag is omitted
231
+ // and Claude picks its own default.
232
+ effortLevel?: EffortLevel;
214
233
  }
215
234
 
216
235
  export function buildCliArgs(params: CliArgsParams): string[] {
217
- const { systemPrompt, activePlugins, claudeSessionId, mcpConfigPath, extraAllowedTools = [] } = params;
236
+ const { systemPrompt, activePlugins, claudeSessionId, mcpConfigPath, extraAllowedTools = [], effortLevel } = params;
218
237
 
219
238
  const mcpToolNames = activePlugins.map((pluginName) => `mcp__mulmoclaude__${pluginName}`);
220
239
  // DEBUG: also pass the wildcard form `mcp__mulmoclaude` so Claude
@@ -264,6 +283,10 @@ export function buildCliArgs(params: CliArgsParams): string[] {
264
283
  args.push("--strict-mcp-config");
265
284
  }
266
285
 
286
+ if (effortLevel) {
287
+ args.push("--effort", effortLevel);
288
+ }
289
+
267
290
  return args;
268
291
  }
269
292
 
@@ -6,6 +6,7 @@ import { refreshCredentials } from "../system/credentials.js";
6
6
  import { loadMcpConfig, loadSettings } from "../system/config.js";
7
7
  import type { Role } from "../../src/config/roles.js";
8
8
  import { buildSystemPrompt } from "./prompt.js";
9
+ import { loadMemorySnapshot } from "../workspace/memory/snapshot.js";
9
10
  import { CONTAINER_WORKSPACE_PATH, buildMcpConfig, getActivePlugins, prepareUserServers, resolveMcpConfigPaths, userServerAllowedToolNames } from "./config.js";
10
11
  import { validateStdioPackages } from "./mcpHealth.js";
11
12
  import type { Attachment } from "@mulmobridge/protocol";
@@ -65,11 +66,15 @@ export async function* runAgent({
65
66
  await refreshCredentials();
66
67
  }
67
68
 
69
+ // Pre-load memory once (atomic vs topic format chosen inside
70
+ // `loadMemorySnapshot`) so prompt assembly itself stays sync.
71
+ const memorySnapshot = await loadMemorySnapshot(workspacePath);
68
72
  const fullSystemPrompt = buildSystemPrompt({
69
73
  role,
70
74
  workspacePath: useDocker ? CONTAINER_WORKSPACE_PATH : workspacePath,
71
75
  useDocker,
72
76
  userTimezone,
77
+ memorySnapshot,
73
78
  });
74
79
 
75
80
  // --debug: dump the full system prompt on the first message of each session.
@@ -134,6 +139,7 @@ export async function* runAgent({
134
139
  activePlugins,
135
140
  mcpConfigPath: hasMcp ? mcpPaths.argPath : undefined,
136
141
  extraAllowedTools: [...settings.extraAllowedTools, ...userServerAllowedTools],
142
+ effortLevel: settings.effortLevel,
137
143
  abortSignal,
138
144
  userTimezone,
139
145
  useDocker,
@@ -1,10 +1,8 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
- import { loadAllMemoryEntriesSync } from "../workspace/memory/io.js";
4
3
  import type { MemoryEntry } from "../workspace/memory/types.js";
5
- import { hasTopicFormat } from "../workspace/memory/topic-detect.js";
6
- import { loadAllTopicFilesSync } from "../workspace/memory/topic-io.js";
7
4
  import type { TopicMemoryFile } from "../workspace/memory/topic-types.js";
5
+ import type { MemorySnapshot } from "../workspace/memory/snapshot.js";
8
6
  import type { Role } from "../../src/config/roles.js";
9
7
  import { getActiveToolDescriptors, MCP_SERVER_ID } from "./activeTools.js";
10
8
  import { WORKSPACE_DIRS, WORKSPACE_FILES } from "../workspace/paths.js";
@@ -68,6 +66,18 @@ Treat the markers as the source of truth for **which** files the user means when
68
66
 
69
67
  When the user wants to transform existing images, call \`editImages\` with \`imagePaths\` set to an array of one or more workspace paths (single image: a one-element array). Pull the paths from the \`[Attached file: …]\` markers, from earlier tool results in this conversation, or from explicit paths the user mentions in plain text. When several markers are present and the request reads as a multi-image instruction ("combine these", "merge", "use both", etc.), include every relevant path in the array, in the order they appeared. \`editImages\` is fully stateless — it has no concept of a "currently selected" image, so the array is the only signal of which images to edit.
70
68
 
69
+ ## Referring to files in chat replies
70
+
71
+ When you finish creating, updating, or surfacing a file in your reply (PDF, Markdown, HTML, image, spreadsheet, chart, etc.), present it to the user as a **Markdown link**:
72
+
73
+ \`[<short label or filename>](<workspace-relative-path>)\`
74
+
75
+ - ALWAYS use the Markdown link form so the UI renders it as a clickable link. Example: \`[summary.pdf](artifacts/documents/2026/05/summary.pdf)\`, or \`[updated wiki](data/wiki/pages/notes.md)\`.
76
+ - NEVER write the path as inline code (e.g. \`\\\`artifacts/foo.pdf\\\`\`) — that renders as non-clickable code and forces the user to copy / paste.
77
+ - NEVER write the path as plain text (e.g. "Open artifacts/foo.pdf to review") — same problem.
78
+ - The link path is the same **workspace-relative** form used everywhere else: no leading slash, no \`file://\`, no \`/api/files/...\` URL. The host resolves it to the right surface (Files panel preview / wiki page / canvas) when the user clicks.
79
+ - A short follow-up sentence like "Open it to review" or "ご確認ください" is fine, but the path itself MUST be inside the \`[...](...)\` wrapper.
80
+
71
81
  ## Task Scheduling
72
82
 
73
83
  Skills and tasks can be scheduled via SKILL.md frontmatter (\`schedule: "daily HH:MM"\` or \`schedule: "interval Nh"\`). When the user asks to schedule something, recommend an appropriate frequency:
@@ -154,24 +164,24 @@ export function prependJournalPointer(message: string, workspacePath: string): s
154
164
  // `readTypedMemoryEntries` / `readLegacyMemoryFile` /
155
165
  // `formatMemoryEntryForPrompt` go with them. See
156
166
  // `server/index.ts` for the full cleanup sweep.
157
- export function buildMemoryContext(workspacePath: string): string {
167
+ export function buildMemoryContext(snapshot: MemorySnapshot, workspacePath: string): string {
158
168
  const parts: string[] = [];
159
169
 
160
- if (hasTopicFormat(workspacePath)) {
170
+ if (snapshot.format === "topic") {
161
171
  // Post-swap (topic format active): each topic file lands in the
162
172
  // prompt as a single block — header + section index + body.
163
173
  // The atomic / legacy readers are intentionally skipped here:
164
174
  // once the topic layout is in place the user has acknowledged
165
175
  // the cluster and the atomic entries have been parked under
166
176
  // `.atomic-backup/`.
167
- const topic = readTopicMemoryEntries(workspacePath);
177
+ const topic = formatTopicFiles(snapshot.files);
168
178
  if (topic) parts.push(topic);
169
179
  } else {
170
180
  // Pre-swap: union of typed atomic entries (#1029) and the
171
181
  // legacy `memory.md` (#1029 PR-A). Same dual-mode behaviour
172
182
  // PR-B of #1029 shipped — preserved unchanged here so users
173
183
  // without topic format keep seeing their memory.
174
- const atomic = readTypedMemoryEntries(workspacePath);
184
+ const atomic = formatTypedMemoryEntries(snapshot.entries);
175
185
  if (atomic) parts.push(atomic);
176
186
  const legacy = readLegacyMemoryFile(workspacePath);
177
187
  if (legacy) parts.push(legacy);
@@ -283,15 +293,20 @@ Keep entries short — name + description + a few lines of body at most. Bias to
283
293
  // the workspace uses the topic layout (post-#1070 swap), emits the
284
294
  // topic-format rules (find-or-create `<type>/<topic>.md`, append
285
295
  // bullets under H2). Otherwise emits the atomic-format rules from
286
- // #1029 PR-B (one fact per `<type>_<slug>.md`). The same disk
287
- // signal that drives `buildMemoryContext` decides which one to
288
- // emit, so write rules and read context are always consistent.
289
- export function buildMemoryManagementSection(workspacePath: string): string {
290
- return hasTopicFormat(workspacePath) ? TOPIC_MEMORY_MANAGEMENT : ATOMIC_MEMORY_MANAGEMENT;
296
+ // #1029 PR-B (one fact per `<type>_<slug>.md`). Both this section
297
+ // and `buildMemoryContext` derive format from the same `snapshot`
298
+ // so write rules and read context stay consistent — including in
299
+ // Docker runs where `workspacePath="/workspace"` doesn't match the
300
+ // host path the snapshot was loaded from (Codex review on #1280).
301
+ export function buildMemoryManagementSection(snapshot: MemorySnapshot): string {
302
+ return snapshot.format === "topic" ? TOPIC_MEMORY_MANAGEMENT : ATOMIC_MEMORY_MANAGEMENT;
291
303
  }
292
304
 
293
- function readTopicMemoryEntries(workspacePath: string): string | null {
294
- const files = loadAllTopicFilesSync(workspacePath);
305
+ // Pure formatters I/O happens once via `loadMemorySnapshot` before
306
+ // `buildSystemPrompt` is called (see `server/agent/index.ts`). Keeps
307
+ // prompt assembly side-effect-free per section.
308
+
309
+ function formatTopicFiles(files: readonly TopicMemoryFile[]): string | null {
295
310
  if (files.length === 0) return null;
296
311
  return files.map(formatTopicFileForPrompt).join("\n\n---\n\n");
297
312
  }
@@ -303,13 +318,7 @@ function formatTopicFileForPrompt(file: TopicMemoryFile): string {
303
318
  return body ? `${tagLine}\n${body}` : tagLine;
304
319
  }
305
320
 
306
- function readTypedMemoryEntries(workspacePath: string): string | null {
307
- // Use the validated loader rather than reading raw files directly:
308
- // a corrupt frontmatter (mid-edit, malformed YAML) is logged and
309
- // skipped by `loadAllMemoryEntriesSync` instead of leaking into the
310
- // system prompt. This also keeps the skip rules (MEMORY.md /
311
- // dotfiles / non-files) defined in exactly one place.
312
- const entries = loadAllMemoryEntriesSync(workspacePath);
321
+ function formatTypedMemoryEntries(entries: readonly MemoryEntry[]): string | null {
313
322
  if (entries.length === 0) return null;
314
323
  return entries.map(formatMemoryEntryForPrompt).join("\n\n");
315
324
  }
@@ -522,6 +531,10 @@ export interface SystemPromptParams {
522
531
  * user every turn. Missing or invalid values fall back to
523
532
  * server-local date only. */
524
533
  userTimezone?: string;
534
+ /** Pre-loaded memory snapshot — caller awaits `loadMemorySnapshot`
535
+ * before invoking `buildSystemPrompt` so prompt assembly stays
536
+ * synchronous and side-effect-free for the memory section. */
537
+ memorySnapshot: MemorySnapshot;
525
538
  }
526
539
 
527
540
  // Accept IANA-looking strings only. Anything else (including
@@ -681,15 +694,15 @@ interface NamedSection {
681
694
  const SYSTEM_PROMPT_WARN_THRESHOLD_CHARS = 20000;
682
695
 
683
696
  export function buildSystemPrompt(params: SystemPromptParams): string {
684
- const { role, workspacePath, useDocker, userTimezone } = params;
697
+ const { role, workspacePath, useDocker, userTimezone, memorySnapshot } = params;
685
698
 
686
699
  const sections: NamedSection[] = [
687
700
  { name: "base", content: SYSTEM_PROMPT },
688
701
  { name: "role", content: role.prompt },
689
702
  { name: "workspace", content: `Workspace directory: ${workspacePath}` },
690
703
  { name: "time", content: buildTimeSection(new Date(), userTimezone) },
691
- { name: "memory", content: buildMemoryContext(workspacePath) },
692
- { name: "memory-management", content: buildMemoryManagementSection(workspacePath) },
704
+ { name: "memory", content: buildMemoryContext(memorySnapshot, workspacePath) },
705
+ { name: "memory-management", content: buildMemoryManagementSection(memorySnapshot) },
693
706
  { name: "sandbox", content: useDocker ? SANDBOX_TOOLS_HINT : null },
694
707
  { name: "wiki", content: buildWikiContext(workspacePath) },
695
708
  { name: "sources", content: buildSourcesContext(workspacePath) },
@@ -36,6 +36,7 @@ import { ACCOUNTING_ACTIONS } from "../../../src/plugins/accounting/actions.js";
36
36
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
37
37
  import { bindRoute } from "../../utils/router.js";
38
38
  import { log } from "../../system/logger/index.js";
39
+ import { asyncHandler } from "../../utils/asyncHandler.js";
39
40
 
40
41
  const router = Router();
41
42
 
@@ -335,32 +336,38 @@ async function dispatch(body: AccountingActionBody): Promise<unknown> {
335
336
  bindRoute(
336
337
  router,
337
338
  API_ROUTES.accounting.dispatch,
338
- async (req: Request<object, unknown, AccountingActionBody>, res: Response<unknown | AccountingErrorResponse>) => {
339
- // Validate the body shape up front so a missing / non-object body
340
- // surfaces as a 400 instead of crashing `dispatch` and bubbling
341
- // through to the 500 catch-all.
342
- const { body } = req;
343
- if (!body || typeof body !== "object" || typeof body.action !== "string") {
344
- log.warn("accounting", "POST dispatch: invalid body");
345
- res.status(400).json({ error: "request body must be an object with a string `action` field" });
346
- return;
347
- }
348
- const { action } = body;
349
- log.info("accounting", "POST dispatch: start", { action });
350
- try {
351
- const result = await dispatch(body);
352
- log.info("accounting", "POST dispatch: ok", { action });
353
- res.json(result);
354
- } catch (err) {
355
- if (err instanceof AccountingError) {
356
- log.warn("accounting", "POST dispatch: error", { action, status: err.status, message: err.message });
357
- res.status(err.status).json({ error: err.message, details: err.details });
339
+ asyncHandler<Request<object, unknown, AccountingActionBody>, Response<unknown | AccountingErrorResponse>>(
340
+ "accounting",
341
+ "accounting dispatch failed",
342
+ async (req, res) => {
343
+ // Validate the body shape up front so a missing / non-object body
344
+ // surfaces as a 400 instead of crashing `dispatch` and bubbling
345
+ // through to the 500 catch-all.
346
+ const { body } = req;
347
+ if (!body || typeof body !== "object" || typeof body.action !== "string") {
348
+ log.warn("accounting", "POST dispatch: invalid body");
349
+ res.status(400).json({ error: "request body must be an object with a string `action` field" });
358
350
  return;
359
351
  }
360
- log.error("accounting", "POST dispatch: unexpected error", { action, error: err instanceof Error ? err.message : String(err) });
361
- res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
362
- }
363
- },
352
+ const { action } = body;
353
+ log.info("accounting", "POST dispatch: start", { action });
354
+ try {
355
+ const result = await dispatch(body);
356
+ log.info("accounting", "POST dispatch: ok", { action });
357
+ res.json(result);
358
+ } catch (err) {
359
+ // Domain errors (AccountingError) map to 4xx with `details`.
360
+ // Anything else rethrows — the asyncHandler wrapper catches
361
+ // it, logs `unexpected error`, and returns a generic 500.
362
+ if (err instanceof AccountingError) {
363
+ log.warn("accounting", "POST dispatch: error", { action, status: err.status, message: err.message });
364
+ res.status(err.status).json({ error: err.message, details: err.details });
365
+ return;
366
+ }
367
+ throw err;
368
+ }
369
+ },
370
+ ),
364
371
  );
365
372
 
366
373
  export default router;
@@ -27,6 +27,7 @@ import { maybeIndexSession } from "../../workspace/chat-index/index.js";
27
27
  import { maybeAppendWikiBacklinks } from "../../workspace/wiki-backlinks/index.js";
28
28
  import { log } from "../../system/logger/index.js";
29
29
  import { logBackgroundError } from "../../utils/logBackgroundError.js";
30
+ import { errorMessage } from "../../utils/errors.js";
30
31
  import { createArgsCache, recordToolEvent } from "../../workspace/tool-trace/index.js";
31
32
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
32
33
  import { EVENT_TYPES } from "../../../src/types/events.js";
@@ -46,7 +47,6 @@ import { env } from "../../system/env.js";
46
47
  import type { Attachment } from "@mulmobridge/protocol";
47
48
  import { isImagePath, loadImageBase64 } from "../../utils/files/image-store.js";
48
49
  import { isAttachmentPath, loadAttachmentBase64, inferMimeFromExtension, saveAttachment } from "../../utils/files/attachment-store.js";
49
- import { errorMessage } from "../../utils/errors.js";
50
50
 
51
51
  const router = Router();
52
52
  const PORT = env.port;
@@ -810,7 +810,7 @@ async function resolveSkillMetadata(skillName: string): Promise<SkillMetadata> {
810
810
  // can still collapse it; just leave metadata empty.
811
811
  log.warn("agent", "skill metadata lookup failed — emitting entry without scope/path/description/body", {
812
812
  skillName,
813
- error: err instanceof Error ? err.message : String(err),
813
+ error: errorMessage(err),
814
814
  });
815
815
  return { scope: "unknown", path: null, description: null, body: null };
816
816
  }
@@ -0,0 +1,49 @@
1
+ // POST /api/config/refresh — wraps `refreshScheduledSkills()` +
2
+ // `refreshUserTasks()` into one endpoint so the config-refresh
3
+ // PostToolUse hook (#1283) can fire-and-forget after Write/Edit of
4
+ // the relevant config files without knowing which refreshers exist.
5
+ // Serves the `mc-manage-skills` + `mc-manage-automations` preset
6
+ // skills (split out from the original `mc-settings` in #1295).
7
+ //
8
+ // Best-effort by design: failures from one refresher don't block the
9
+ // other; the response is always 200 with a per-refresher status so the
10
+ // caller (the hook) can log on errors but never has to abort.
11
+
12
+ import { Router, Request, Response } from "express";
13
+ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
14
+ import { log } from "../../system/logger/index.js";
15
+ import { errorMessage } from "../../utils/errors.js";
16
+ import { refreshScheduledSkills } from "../../workspace/skills/scheduler.js";
17
+ import { refreshUserTasks } from "../../workspace/skills/user-tasks.js";
18
+
19
+ const router = Router();
20
+
21
+ interface RefreshOutcome {
22
+ ok: boolean;
23
+ count?: number;
24
+ error?: string;
25
+ }
26
+
27
+ interface RefreshResponse {
28
+ skills: RefreshOutcome;
29
+ userTasks: RefreshOutcome;
30
+ }
31
+
32
+ async function safeRefresh(label: string, refresher: () => Promise<number>): Promise<RefreshOutcome> {
33
+ try {
34
+ const count = await refresher();
35
+ return { ok: true, count };
36
+ } catch (err) {
37
+ const error = errorMessage(err);
38
+ log.warn("config-refresh", `${label} refresh failed`, { error });
39
+ return { ok: false, error };
40
+ }
41
+ }
42
+
43
+ router.post(API_ROUTES.config.refresh, async (_req: Request, res: Response<RefreshResponse>) => {
44
+ const [skills, userTasks] = await Promise.all([safeRefresh("skills", refreshScheduledSkills), safeRefresh("userTasks", refreshUserTasks)]);
45
+ log.debug("config-refresh", "refresh complete", { skills, userTasks });
46
+ res.json({ skills, userTasks });
47
+ });
48
+
49
+ export default router;
@@ -5,10 +5,12 @@ import {
5
5
  isAppSettingsPatch,
6
6
  loadMcpConfig,
7
7
  loadSettings,
8
+ normaliseAppSettingsPatch,
8
9
  saveMcpConfig,
9
10
  saveSettings,
10
11
  toMcpEntries,
11
12
  type AppSettings,
13
+ type AppSettingsPatch,
12
14
  type McpConfigFile,
13
15
  type McpServerEntry,
14
16
  } from "../../system/config.js";
@@ -17,6 +19,7 @@ import { errorMessage } from "../../utils/errors.js";
17
19
  import { isRecord } from "../../utils/types.js";
18
20
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
19
21
  import { log } from "../../system/logger/index.js";
22
+ import { asyncHandler } from "../../utils/asyncHandler.js";
20
23
  import { loadCustomDirs, saveCustomDirs, ensureCustomDirs, validateCustomDirs, type CustomDirEntry } from "../../workspace/custom-dirs.js";
21
24
  import { loadReferenceDirs, saveReferenceDirs, validateReferenceDirs, type ReferenceDirEntry } from "../../workspace/reference-dirs.js";
22
25
 
@@ -67,16 +70,18 @@ function parseMcpPayloadOrFail(res: ConfigRes, servers: McpServerEntry[]): McpCo
67
70
  }
68
71
  }
69
72
 
70
- // Run a filesystem save. On failure, respond 500 with the error's
71
- // message and return false so the caller can early-return. Returns
72
- // true on success.
73
+ // Run a filesystem save. On failure, log the raw error server-side
74
+ // (full triage detail kept in logs), respond 500 with the safe
75
+ // `fallback` message, and return false so the caller can early-return.
76
+ // Returns true on success. The raw `err.message` deliberately doesn't
77
+ // reach the client — same threat model as `asyncHandler`.
73
78
  function runSaveOrFail(res: ConfigRes, save: () => void, fallback: string): boolean {
74
79
  try {
75
80
  save();
76
81
  return true;
77
82
  } catch (err) {
78
83
  log.error("config", `save failed: ${fallback}`, { error: errorMessage(err) });
79
- serverError(res, errorMessage(err, fallback));
84
+ serverError(res, fallback);
80
85
  return false;
81
86
  }
82
87
  }
@@ -134,7 +139,7 @@ router.put(API_ROUTES.config.base, (req: Request<unknown, unknown, PutConfigBody
134
139
  res.json(buildFullResponse());
135
140
  });
136
141
 
137
- router.put(API_ROUTES.config.settings, (req: Request<unknown, unknown, Partial<AppSettings>>, res: ConfigRes) => {
142
+ router.put(API_ROUTES.config.settings, (req: Request<unknown, unknown, AppSettingsPatch>, res: ConfigRes) => {
138
143
  const { body } = req;
139
144
  log.info("config", "PUT settings: start");
140
145
  if (!isAppSettingsPatch(body)) {
@@ -147,8 +152,16 @@ router.put(API_ROUTES.config.settings, (req: Request<unknown, unknown, Partial<A
147
152
  // that knows about only some fields (e.g. Tools tab sends only
148
153
  // `extraAllowedTools`, Map tab sends only `googleMapsApiKey`)
149
154
  // doesn't wipe fields owned by other tabs.
155
+ //
156
+ // `null` in the patch is a sentinel for "clear this field":
157
+ // normaliseAppSettingsPatch drops the entry from the patch, AND we
158
+ // must also delete it from the merged result so the existing
159
+ // value doesn't leak through the spread.
150
160
  const existing = loadSettings();
151
- const merged: AppSettings = { ...existing, ...body };
161
+ const merged: AppSettings = { ...existing, ...normaliseAppSettingsPatch(body) };
162
+ if (body.effortLevel === null) {
163
+ delete merged.effortLevel;
164
+ }
152
165
  if (!runSaveOrFail(res, () => saveSettings(merged), "saveSettings failed")) {
153
166
  return;
154
167
  }
@@ -183,30 +196,29 @@ router.get(API_ROUTES.config.workspaceDirs, (_req: Request, res: Response<{ dirs
183
196
 
184
197
  router.put(
185
198
  API_ROUTES.config.workspaceDirs,
186
- (req: Request<unknown, unknown, { dirs: unknown }>, res: Response<{ dirs: CustomDirEntry[] } | ConfigErrorResponse>) => {
187
- const { body } = req;
188
- log.info("config", "PUT workspace-dirs: start");
189
- if (!isRecord(body) || !("dirs" in body)) {
190
- log.warn("config", "PUT workspace-dirs: invalid envelope");
191
- badRequest(res, "expected { dirs: [...] }");
192
- return;
193
- }
194
- const result = validateCustomDirs(body.dirs);
195
- if ("error" in result) {
196
- log.warn("config", "PUT workspace-dirs: validation failed", { error: result.error });
197
- badRequest(res, result.error);
198
- return;
199
- }
200
- try {
199
+ asyncHandler<Request<unknown, unknown, { dirs: unknown }>, Response<{ dirs: CustomDirEntry[] } | ConfigErrorResponse>>(
200
+ "config",
201
+ "save failed",
202
+ async (req, res) => {
203
+ const { body } = req;
204
+ log.info("config", "PUT workspace-dirs: start");
205
+ if (!isRecord(body) || !("dirs" in body)) {
206
+ log.warn("config", "PUT workspace-dirs: invalid envelope");
207
+ badRequest(res, "expected { dirs: [...] }");
208
+ return;
209
+ }
210
+ const result = validateCustomDirs(body.dirs);
211
+ if ("error" in result) {
212
+ log.warn("config", "PUT workspace-dirs: validation failed", { error: result.error });
213
+ badRequest(res, result.error);
214
+ return;
215
+ }
201
216
  saveCustomDirs(result.entries);
202
217
  ensureCustomDirs(result.entries);
203
218
  log.info("config", "PUT workspace-dirs: ok", { dirs: result.entries.length });
204
219
  res.json({ dirs: result.entries });
205
- } catch (err) {
206
- log.error("config", "PUT workspace-dirs: threw", { error: errorMessage(err) });
207
- serverError(res, errorMessage(err, "save failed"));
208
- }
209
- },
220
+ },
221
+ ),
210
222
  );
211
223
 
212
224
  // ── Reference directories (#455) ────────────────────────────────
@@ -217,29 +229,28 @@ router.get(API_ROUTES.config.referenceDirs, (_req: Request, res: Response<{ dirs
217
229
 
218
230
  router.put(
219
231
  API_ROUTES.config.referenceDirs,
220
- (req: Request<unknown, unknown, { dirs: unknown }>, res: Response<{ dirs: ReferenceDirEntry[] } | ConfigErrorResponse>) => {
221
- const { body } = req;
222
- log.info("config", "PUT reference-dirs: start");
223
- if (!isRecord(body) || !("dirs" in body)) {
224
- log.warn("config", "PUT reference-dirs: invalid envelope");
225
- badRequest(res, "expected { dirs: [...] }");
226
- return;
227
- }
228
- const result = validateReferenceDirs(body.dirs);
229
- if ("error" in result) {
230
- log.warn("config", "PUT reference-dirs: validation failed", { error: result.error });
231
- badRequest(res, result.error);
232
- return;
233
- }
234
- try {
232
+ asyncHandler<Request<unknown, unknown, { dirs: unknown }>, Response<{ dirs: ReferenceDirEntry[] } | ConfigErrorResponse>>(
233
+ "config",
234
+ "save failed",
235
+ async (req, res) => {
236
+ const { body } = req;
237
+ log.info("config", "PUT reference-dirs: start");
238
+ if (!isRecord(body) || !("dirs" in body)) {
239
+ log.warn("config", "PUT reference-dirs: invalid envelope");
240
+ badRequest(res, "expected { dirs: [...] }");
241
+ return;
242
+ }
243
+ const result = validateReferenceDirs(body.dirs);
244
+ if ("error" in result) {
245
+ log.warn("config", "PUT reference-dirs: validation failed", { error: result.error });
246
+ badRequest(res, result.error);
247
+ return;
248
+ }
235
249
  saveReferenceDirs(result.entries);
236
250
  log.info("config", "PUT reference-dirs: ok", { dirs: result.entries.length });
237
251
  res.json({ dirs: result.entries });
238
- } catch (err) {
239
- log.error("config", "PUT reference-dirs: threw", { error: errorMessage(err) });
240
- serverError(res, errorMessage(err, "save failed"));
241
- }
242
- },
252
+ },
253
+ ),
243
254
  );
244
255
 
245
256
  router.get(API_ROUTES.config.schedulerOverrides, (_req: Request, res: Response<{ overrides: ScheduleOverrides }>) => {
@@ -248,22 +259,24 @@ router.get(API_ROUTES.config.schedulerOverrides, (_req: Request, res: Response<{
248
259
 
249
260
  router.put(
250
261
  API_ROUTES.config.schedulerOverrides,
251
- async (req: Request<unknown, unknown, { overrides: unknown }>, res: Response<{ overrides: ScheduleOverrides } | ConfigErrorResponse>) => {
252
- const { body } = req;
253
- log.info("config", "PUT scheduler-overrides: start");
254
- if (!isRecord(body) || !("overrides" in body)) {
255
- log.warn("config", "PUT scheduler-overrides: invalid envelope");
256
- badRequest(res, "expected { overrides: { ... } }");
257
- return;
258
- }
259
- const raw = body.overrides;
260
- if (!isRecord(raw)) {
261
- log.warn("config", "PUT scheduler-overrides: overrides not an object");
262
- badRequest(res, "overrides must be an object");
263
- return;
264
- }
265
- const overrides = raw as ScheduleOverrides;
266
- try {
262
+ asyncHandler<Request<unknown, unknown, { overrides: unknown }>, Response<{ overrides: ScheduleOverrides } | ConfigErrorResponse>>(
263
+ "config",
264
+ "save failed",
265
+ async (req, res) => {
266
+ const { body } = req;
267
+ log.info("config", "PUT scheduler-overrides: start");
268
+ if (!isRecord(body) || !("overrides" in body)) {
269
+ log.warn("config", "PUT scheduler-overrides: invalid envelope");
270
+ badRequest(res, "expected { overrides: { ... } }");
271
+ return;
272
+ }
273
+ const raw = body.overrides;
274
+ if (!isRecord(raw)) {
275
+ log.warn("config", "PUT scheduler-overrides: overrides not an object");
276
+ badRequest(res, "overrides must be an object");
277
+ return;
278
+ }
279
+ const overrides = raw as ScheduleOverrides;
267
280
  saveSchedulerOverrides(overrides);
268
281
 
269
282
  // Apply to running task-manager immediately
@@ -283,11 +296,8 @@ router.put(
283
296
 
284
297
  log.info("config", "PUT scheduler-overrides: ok", { tasks: Object.keys(overrides).length });
285
298
  res.json({ overrides: loadSchedulerOverrides() });
286
- } catch (err) {
287
- log.error("config", "PUT scheduler-overrides: threw", { error: errorMessage(err) });
288
- serverError(res, errorMessage(err, "save failed"));
289
- }
290
- },
299
+ },
300
+ ),
291
301
  );
292
302
 
293
303
  export default router;