mulmoclaude 0.6.0 → 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 (172) 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-BbgSjFQ8.js → index-BwrlMMHr.js} +178 -141
  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 +9 -7
  11. package/server/accounting/eventPublisher.ts +2 -1
  12. package/server/accounting/snapshotCache.ts +2 -1
  13. package/server/agent/activeTools.ts +16 -6
  14. package/server/agent/backend/claude-code.ts +1 -0
  15. package/server/agent/backend/types.ts +3 -0
  16. package/server/agent/config.ts +25 -2
  17. package/server/agent/index.ts +6 -0
  18. package/server/agent/mcp-server.ts +9 -6
  19. package/server/agent/mcp-tools/index.ts +15 -2
  20. package/server/agent/mcp-tools/notify.ts +20 -2
  21. package/server/agent/prompt.ts +37 -24
  22. package/server/api/routes/accounting.ts +31 -24
  23. package/server/api/routes/agent.ts +2 -2
  24. package/server/api/routes/config-refresh.ts +49 -0
  25. package/server/api/routes/config.ts +86 -68
  26. package/server/api/routes/files.ts +41 -17
  27. package/server/api/routes/hookLog.ts +95 -0
  28. package/server/api/routes/news.ts +39 -52
  29. package/server/api/routes/notifier.ts +14 -19
  30. package/server/api/routes/pdf.ts +2 -2
  31. package/server/api/routes/photo-locations.ts +79 -0
  32. package/server/api/routes/plugins.ts +11 -0
  33. package/server/api/routes/presentSvg.ts +107 -0
  34. package/server/api/routes/scheduler.ts +100 -98
  35. package/server/api/routes/schedulerTasks.ts +98 -95
  36. package/server/api/routes/sessions.ts +22 -27
  37. package/server/api/routes/sources.ts +45 -43
  38. package/server/api/routes/wiki/history.ts +6 -15
  39. package/server/api/routes/wiki.ts +73 -276
  40. package/server/events/file-change.ts +3 -2
  41. package/server/events/session-store/index.ts +2 -1
  42. package/server/index.ts +130 -8
  43. package/server/notifier/store.ts +3 -3
  44. package/server/plugins/preset-list.ts +16 -5
  45. package/server/plugins/runtime.ts +2 -2
  46. package/server/system/config.ts +138 -16
  47. package/server/utils/asyncHandler.ts +75 -0
  48. package/server/utils/exif.ts +321 -0
  49. package/server/utils/files/accounting-io.ts +19 -20
  50. package/server/utils/files/attachment-store.ts +69 -12
  51. package/server/utils/files/journal-io.ts +2 -1
  52. package/server/utils/files/json.ts +8 -1
  53. package/server/utils/files/reference-dirs-io.ts +2 -3
  54. package/server/utils/files/scheduler-overrides-io.ts +2 -3
  55. package/server/utils/files/svg-store.ts +27 -0
  56. package/server/utils/files/user-tasks-io.ts +2 -3
  57. package/server/utils/regex.ts +3 -12
  58. package/server/utils/text.ts +29 -0
  59. package/server/workspace/chat-index/summarizer.ts +5 -3
  60. package/server/workspace/cooking-recipes/migrate.ts +125 -0
  61. package/server/workspace/custom-dirs.ts +2 -2
  62. package/server/workspace/hooks/dispatcher.mjs +300 -0
  63. package/server/workspace/hooks/dispatcher.ts +55 -0
  64. package/server/workspace/hooks/handlers/configRefresh.ts +38 -0
  65. package/server/workspace/hooks/handlers/skillBridge.ts +223 -0
  66. package/server/workspace/hooks/handlers/wikiSnapshot.ts +43 -0
  67. package/server/workspace/hooks/provision.ts +222 -0
  68. package/server/workspace/hooks/shared/sidecar.ts +124 -0
  69. package/server/workspace/hooks/shared/stdin.ts +60 -0
  70. package/server/workspace/hooks/shared/workspace.ts +13 -0
  71. package/server/workspace/journal/dailyPass.ts +1 -6
  72. package/server/workspace/memory/io.ts +1 -34
  73. package/server/workspace/memory/migrate.ts +2 -1
  74. package/server/workspace/memory/snapshot.ts +26 -0
  75. package/server/workspace/memory/topic-io.ts +1 -18
  76. package/server/workspace/paths.ts +16 -0
  77. package/server/workspace/photo-locations/index.ts +149 -0
  78. package/server/workspace/photo-locations/list.ts +124 -0
  79. package/server/workspace/skills-preset/mc-cooking-coach/SKILL.md +217 -0
  80. package/server/workspace/skills-preset/mc-manage-automations/SKILL.md +119 -0
  81. package/server/workspace/skills-preset/mc-manage-skills/SKILL.md +128 -0
  82. package/server/workspace/skills-preset/mc-manage-sources/SKILL.md +106 -0
  83. package/server/workspace/skills-preset.ts +2 -1
  84. package/server/workspace/wiki-pages/io.ts +2 -1
  85. package/src/App.vue +78 -3
  86. package/src/components/ChatInput.vue +7 -8
  87. package/src/components/FileContentHeader.vue +1 -6
  88. package/src/components/FileDropOverlay.vue +18 -0
  89. package/src/components/NewsView.vue +2 -1
  90. package/src/components/RolesView.vue +14 -5
  91. package/src/components/SettingsMapTab.vue +140 -0
  92. package/src/components/SettingsMcpTab.vue +15 -10
  93. package/src/components/SettingsModal.vue +138 -112
  94. package/src/components/SettingsModelTab.vue +121 -0
  95. package/src/components/SettingsPhotosTab.vue +118 -0
  96. package/src/components/SourcesManager.vue +4 -3
  97. package/src/components/StackView.vue +43 -12
  98. package/src/composables/useContentDisplay.ts +16 -0
  99. package/src/composables/useFileDropZone.ts +148 -0
  100. package/src/composables/useImageErrorRepair.ts +29 -19
  101. package/src/composables/useSkillsList.ts +2 -1
  102. package/src/config/apiRoutes.ts +24 -0
  103. package/src/config/roles.ts +121 -70
  104. package/src/config/systemFileDescriptors.ts +2 -2
  105. package/src/config/toolNames.ts +26 -0
  106. package/src/index.css +26 -0
  107. package/src/lang/de.ts +70 -1
  108. package/src/lang/en.ts +69 -1
  109. package/src/lang/es.ts +69 -1
  110. package/src/lang/fr.ts +69 -1
  111. package/src/lang/ja.ts +69 -1
  112. package/src/lang/ko.ts +68 -1
  113. package/src/lang/pt-BR.ts +69 -1
  114. package/src/lang/zh.ts +67 -1
  115. package/src/lib/wiki-page/index-parse.ts +221 -0
  116. package/src/lib/wiki-page/link.ts +62 -0
  117. package/src/lib/wiki-page/lint.ts +105 -0
  118. package/src/lib/wiki-page/paths.ts +35 -0
  119. package/src/lib/wiki-page/slug.ts +28 -40
  120. package/src/main.ts +8 -0
  121. package/src/plugins/_extras.ts +6 -2
  122. package/src/plugins/_generated/metas.ts +4 -0
  123. package/src/plugins/_generated/registrations.ts +4 -0
  124. package/src/plugins/_generated/server-bindings.ts +6 -0
  125. package/src/plugins/accounting/Preview.vue +3 -6
  126. package/src/plugins/accounting/View.vue +2 -1
  127. package/src/plugins/accounting/components/AccountsModal.vue +3 -2
  128. package/src/plugins/accounting/components/JournalEntryForm.vue +2 -1
  129. package/src/plugins/accounting/components/JournalList.vue +2 -1
  130. package/src/plugins/accounting/components/OpeningBalancesForm.vue +2 -1
  131. package/src/plugins/accounting/currencies.ts +13 -0
  132. package/src/plugins/manageRoles/View.vue +16 -5
  133. package/src/plugins/manageSkills/View.vue +12 -4
  134. package/src/plugins/markdown/View.vue +6 -0
  135. package/src/plugins/photoLocations/View.vue +231 -0
  136. package/src/plugins/photoLocations/definition.ts +47 -0
  137. package/src/plugins/photoLocations/index.ts +38 -0
  138. package/src/plugins/photoLocations/meta.ts +35 -0
  139. package/src/plugins/presentMulmoScript/View.vue +76 -7
  140. package/src/plugins/presentMulmoScript/helpers.ts +15 -0
  141. package/src/plugins/presentSVG/Preview.vue +56 -0
  142. package/src/plugins/presentSVG/View.vue +465 -0
  143. package/src/plugins/presentSVG/definition.ts +29 -0
  144. package/src/plugins/presentSVG/index.ts +49 -0
  145. package/src/plugins/presentSVG/meta.ts +14 -0
  146. package/src/plugins/scheduler/View.vue +3 -7
  147. package/src/plugins/skill/View.vue +15 -16
  148. package/src/plugins/spreadsheet/View.vue +4 -0
  149. package/src/plugins/wiki/View.vue +1 -1
  150. package/src/plugins/wiki/helpers.ts +23 -5
  151. package/src/plugins/wiki/route.ts +12 -11
  152. package/src/tools/runtimeLoader.ts +75 -9
  153. package/src/utils/dom/iframeHeightClamp.ts +42 -0
  154. package/src/utils/format/bytes.ts +41 -0
  155. package/src/utils/format/date.ts +14 -2
  156. package/src/utils/image/imageRepairInlineScript.ts +192 -41
  157. package/src/utils/markdown/sanitize.ts +68 -0
  158. package/src/utils/markdown/setup.ts +36 -0
  159. package/src/utils/markdown/wikiEmbedHandlers.ts +170 -0
  160. package/src/utils/markdown/wikiEmbeds.ts +141 -0
  161. package/src/utils/markdown/workspaceLinkify.ts +73 -0
  162. package/src/utils/path/workspaceLinkRouter.ts +17 -1
  163. package/client/assets/index-ECD0lgIv.css +0 -2
  164. package/client/assets/material-symbols-outlined-BLDfUw-_.woff2 +0 -0
  165. package/client/assets/runtime-protocol-vue-6WYa8hAs.js +0 -1
  166. package/server/workspace/wiki-history/hook/snapshot.mjs +0 -98
  167. package/server/workspace/wiki-history/hook/snapshot.ts +0 -135
  168. package/server/workspace/wiki-history/provision.ts +0 -181
  169. /package/client/assets/{chunk-D8eiyYIV-C1eAZMzz.js → chunk-D8eiyYIV-CAXpUwLd.js} +0 -0
  170. /package/client/assets/{purify.es-Fx1Nqyry-BSVNht6S.js → purify.es-Fx1Nqyry-Dwtk-9WZ.js} +0 -0
  171. /package/client/assets/{typeof-DBp4T-Ny-C2xoZtcz.js → typeof-DBp4T-Ny-CSr8wx1e.js} +0 -0
  172. /package/client/assets/{vue-1e_vz2LW.js → vue-C8UuIO9J.js} +0 -0
@@ -19,8 +19,11 @@
19
19
  // `getActiveToolDescriptors(role)` produces a single list of
20
20
  // `ActiveToolDescriptor` rows and the three call sites read whichever
21
21
  // fields they need (name only / name + prompt / name + endpoint).
22
- // Runtime plugins are auto-included regardless of role; static plugins
23
- // are still gated by `role.availablePlugins`. The MCP-prefixed full
22
+ // EVERY tool source (static-gui, static-mcp, runtime) is gated by
23
+ // `role.availablePlugins` runtime plugins used to be auto-included
24
+ // regardless, which surfaced as a real bug (preset plugins like
25
+ // `manageRecipes` leaked into every role even though `cookingCoach`
26
+ // was their intended home). The MCP-prefixed full
24
27
  // name is precomputed once so callers don't have to re-derive it.
25
28
 
26
29
  import type { Role } from "../../src/config/roles.js";
@@ -109,10 +112,17 @@ export function getActiveToolDescriptors(role: Role): ActiveToolDescriptor[] {
109
112
  for (const plugin of getRuntimePlugins()) {
110
113
  const def = plugin.definition;
111
114
  if (seen.has(def.name)) continue; // runtime-registry collision
112
- // policy already filters static name collisions, but the
113
- // build-time-bundle case (#1043 C-2 codex iter-7 medium) is not
114
- // currently in the static set; the `seen` guard catches any
115
- // duplicate that slipped through.
115
+ // Runtime plugins (preset + user-installed alike) are now gated
116
+ // by `role.availablePlugins`, mirroring the static-GUI / static-MCP
117
+ // loops above. Previously they were auto-included regardless of
118
+ // role that broke the role's stated promise of "exactly these
119
+ // tools" and surfaced as a real bug when a non-cooking role
120
+ // started seeing `manageRecipes`. Roles that want a runtime
121
+ // plugin must list its `toolName` in `availablePlugins`. The
122
+ // Settings → Roles UI lets users add tool names per role; preset
123
+ // plugin names land in the `general` role's `availablePlugins`
124
+ // out of the box.
125
+ if (!allowed.has(def.name)) continue;
116
126
  out.push({
117
127
  name: def.name,
118
128
  fullName: fullNameFor(def.name),
@@ -143,6 +143,7 @@ async function* runClaudeAgent(input: AgentInput): AsyncGenerator<AgentEvent> {
143
143
  claudeSessionId: input.sessionToken,
144
144
  mcpConfigPath: input.mcpConfigPath,
145
145
  extraAllowedTools: input.extraAllowedTools,
146
+ effortLevel: input.effortLevel,
146
147
  });
147
148
 
148
149
  const proc = spawnClaude(input.useDocker, input.workspacePath, cliArgs, input.sessionId);
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { Attachment } from "@mulmobridge/protocol";
11
11
  import type { Role } from "../../../src/config/roles.js";
12
+ import type { EffortLevel } from "../../system/config.js";
12
13
  import type { AgentEvent } from "../stream.js";
13
14
 
14
15
  /** Inputs the orchestrator passes to a backend for one user turn.
@@ -37,6 +38,8 @@ export interface AgentInput {
37
38
  mcpConfigPath?: string;
38
39
  /** Extra allowed-tool names from settings + user MCP servers. */
39
40
  extraAllowedTools: string[];
41
+ /** Reasoning effort from settings (#1323). Undefined → flag omitted. */
42
+ effortLevel?: EffortLevel;
40
43
  /** When fired, the backend must terminate any in-flight
41
44
  * subprocess / connection. */
42
45
  abortSignal?: AbortSignal;
@@ -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,
@@ -162,12 +162,15 @@ const runtimeReady: Promise<void> = (async () => {
162
162
  const endpoint = API_ROUTES.plugins.runtimeDispatch.replace(":pkg", encodeURIComponent(plugin.name));
163
163
  ALL_TOOLS[plugin.definition.name] = fromPackage(plugin.definition, endpoint);
164
164
  }
165
- // Runtime plugins are auto-included regardless of role.availablePlugins
166
- // the user explicitly installed them, so every role gets to use them.
167
- // Roles still gate the static set via PLUGIN_NAMES env (set by the
168
- // parent based on getActivePlugins(role)).
169
- const runtimeToolNames = getRuntimePlugins().map((plugin) => plugin.definition.name);
170
- activeNames = Array.from(new Set([...PLUGIN_NAMES, ...runtimeToolNames]));
165
+ // Runtime plugins are gated by `role.availablePlugins` (mirrored
166
+ // here through the PLUGIN_NAMES env set by the parent's
167
+ // `getActivePlugins(role)`). Previously every runtime plugin was
168
+ // auto-active in every role, which leaked preset plugins like
169
+ // `manageRecipes` into roles that shouldn't expose them. The
170
+ // intersection is now: ALL_TOOLS includes both static + runtime
171
+ // entries, but only the names PLUGIN_NAMES authorises become live
172
+ // tools.
173
+ activeNames = [...PLUGIN_NAMES];
171
174
  tools = activeNames.map((name) => ALL_TOOLS[name]).filter(Boolean);
172
175
  } catch (err) {
173
176
  process.stderr.write(`[mcp-server] runtime plugin load failed; static tools only: ${String(err)}\n`);
@@ -5,6 +5,17 @@ import { errorMessage } from "../../utils/errors.js";
5
5
  import { notFound, sendError, serverError } from "../../utils/httpError.js";
6
6
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
7
7
 
8
+ // Per-call context the MCP bridge threads through to the tool handler.
9
+ // Currently just the chat session id (extracted from the `?session=`
10
+ // query string the bridge always appends, see mcp-server.ts), so a
11
+ // tool like `notify` can mark its outgoing notification with a
12
+ // click-target back to the originating chat. Optional because the
13
+ // HTTP route is also reachable by non-bridge callers (tests, ad-hoc
14
+ // scripts) that have no session.
15
+ export interface McpToolContext {
16
+ sessionId?: string;
17
+ }
18
+
8
19
  export interface McpTool {
9
20
  definition: {
10
21
  name: string;
@@ -13,7 +24,7 @@ export interface McpTool {
13
24
  };
14
25
  requiredEnv?: string[];
15
26
  prompt?: string;
16
- handler: (args: Record<string, unknown>) => Promise<string>;
27
+ handler: (args: Record<string, unknown>, ctx?: McpToolContext) => Promise<string>;
17
28
  }
18
29
 
19
30
  export const mcpTools: McpTool[] = [readXPost, searchX, notify];
@@ -52,7 +63,9 @@ mcpToolsRouter.post(API_ROUTES.mcpTools.invoke, async (req: Request<McpToolParam
52
63
  return;
53
64
  }
54
65
  try {
55
- const result = await tool.handler(req.body);
66
+ const sessionRaw = typeof req.query.session === "string" ? req.query.session : "";
67
+ const ctx: McpToolContext | undefined = sessionRaw.length > 0 ? { sessionId: sessionRaw } : undefined;
68
+ const result = await tool.handler(req.body, ctx);
56
69
  res.json({ result });
57
70
  } catch (err) {
58
71
  serverError(res, errorMessage(err));
@@ -2,7 +2,8 @@
2
2
  // bell side effects.
3
3
 
4
4
  import { publishNotification } from "../../events/notifications.js";
5
- import { NOTIFICATION_KINDS } from "../../../src/types/notification.js";
5
+ import { NOTIFICATION_ACTION_TYPES, NOTIFICATION_KINDS, NOTIFICATION_VIEWS } from "../../../src/types/notification.js";
6
+ import type { McpToolContext } from "./index.js";
6
7
 
7
8
  export type NotifyPublishFn = typeof publishNotification;
8
9
 
@@ -10,6 +11,21 @@ export interface NotifyToolDeps {
10
11
  publish: NotifyPublishFn;
11
12
  }
12
13
 
14
+ // When the bridge threads a chat session through, mark the
15
+ // notification's primary action as "open the originating chat" so
16
+ // the user can click the bell entry and land back on the session
17
+ // that produced it (typically a scheduled / background chat that
18
+ // finished while they were elsewhere). Without a session id, fall
19
+ // back to plain push — entry is just dismissed on click, which is
20
+ // the unchanged pre-fix behaviour.
21
+ function buildNavigateAction(ctx?: McpToolContext) {
22
+ if (!ctx?.sessionId || ctx.sessionId.length === 0) return undefined;
23
+ return {
24
+ type: NOTIFICATION_ACTION_TYPES.navigate,
25
+ target: { view: NOTIFICATION_VIEWS.chat, sessionId: ctx.sessionId },
26
+ };
27
+ }
28
+
13
29
  export function makeNotifyTool(deps: NotifyToolDeps) {
14
30
  return {
15
31
  definition: {
@@ -37,16 +53,18 @@ export function makeNotifyTool(deps: NotifyToolDeps) {
37
53
  "This is the canonical built-in notification path: it fans out to the web bell, any active bridge transport, and macOS Reminders (when MACOS_REMINDER_NOTIFICATIONS=1 + darwin), and has NO active-user suppression — if the user asks for a notification, fire one. " +
38
54
  "After firing, briefly tell the user you sent the notification.",
39
55
 
40
- async handler(args: Record<string, unknown>): Promise<string> {
56
+ async handler(args: Record<string, unknown>, ctx?: McpToolContext): Promise<string> {
41
57
  const title = typeof args.title === "string" ? args.title.trim() : "";
42
58
  if (!title) return "notify: `title` is required (non-empty string).";
43
59
  const bodyRaw = typeof args.body === "string" ? args.body.trim() : "";
44
60
  const body = bodyRaw.length > 0 ? bodyRaw : undefined;
45
61
 
62
+ const action = buildNavigateAction(ctx);
46
63
  deps.publish({
47
64
  kind: NOTIFICATION_KINDS.push,
48
65
  title,
49
66
  body,
67
+ ...(action ? { action } : {}),
50
68
  });
51
69
  return body ? `Notification sent: ${title}\n${body}` : `Notification sent: ${title}`;
52
70
  },
@@ -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;