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
@@ -14,20 +14,23 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
14
14
  import { bindRoute } from "../../utils/router.js";
15
15
  import { SESSION_ORIGINS } from "../../../src/types/session.js";
16
16
  import { loadUserTasks, validateAndCreate, applyUpdate, withUserTaskLock } from "../../workspace/skills/user-tasks.js";
17
- import { badRequest, notFound, serverError } from "../../utils/httpError.js";
17
+ import { badRequest, notFound } from "../../utils/httpError.js";
18
18
  import { errorMessage } from "../../utils/errors.js";
19
19
  import { getOptionalStringQuery } from "../../utils/request.js";
20
20
  import { log } from "../../system/logger/index.js";
21
21
  import { startChat } from "./agent.js";
22
22
  import { makeUuid } from "../../utils/id.js";
23
+ import { asyncHandler } from "../../utils/asyncHandler.js";
23
24
 
24
25
  const router = Router();
25
26
 
26
27
  // ── List all tasks ──────────────────────────────────────────────
27
28
 
28
- bindRoute(router, API_ROUTES.scheduler.tasksList, (_req: Request, res: Response) => {
29
- log.info("scheduler-tasks", "list: start");
30
- try {
29
+ bindRoute(
30
+ router,
31
+ API_ROUTES.scheduler.tasksList,
32
+ asyncHandler("scheduler-tasks", "Failed to list tasks", async (_req, res) => {
33
+ log.info("scheduler-tasks", "list: start");
31
34
  // getSchedulerTasks() returns system-only tasks (registered via
32
35
  // initScheduler at startup — journal, chat-index, sources, etc.).
33
36
  // origin: "system" is correct, not an overwrite — these tasks
@@ -37,95 +40,92 @@ bindRoute(router, API_ROUTES.scheduler.tasksList, (_req: Request, res: Response)
37
40
  const all = [...systemTasks.map((task) => ({ ...task, origin: "system" as const })), ...userTasks.map((task) => ({ ...task, origin: "user" as const }))];
38
41
  log.info("scheduler-tasks", "list: ok", { system: systemTasks.length, user: userTasks.length });
39
42
  res.json({ tasks: all });
40
- } catch (err) {
41
- // loadUserTasks reads JSON from disk and getSchedulerTasks
42
- // queries the in-memory registry — neither is supposed to
43
- // throw, but a corrupted user-tasks.json or an early-startup
44
- // registry race would. Without the catch the route would 500
45
- // with no trace, which is the original #779 complaint pattern.
46
- log.error("scheduler-tasks", "list: failed", { error: errorMessage(err) });
47
- serverError(res, "Failed to list tasks");
48
- }
49
- });
43
+ }),
44
+ );
50
45
 
51
46
  // ── Create user task ────────────────────────────────────────────
52
47
 
53
- bindRoute(router, API_ROUTES.scheduler.tasksCreate, async (req: Request, res: Response) => {
54
- log.info("scheduler-tasks", "create: start");
55
- const validated = validateAndCreate(req.body);
56
- if (validated.kind === "error") {
57
- log.warn("scheduler-tasks", "create: validation failed", { error: validated.error });
58
- badRequest(res, validated.error);
59
- return;
60
- }
61
- try {
48
+ bindRoute(
49
+ router,
50
+ API_ROUTES.scheduler.tasksCreate,
51
+ asyncHandler("scheduler-tasks", "Failed to create task", async (req, res) => {
52
+ log.info("scheduler-tasks", "create: start");
53
+ const validated = validateAndCreate(req.body);
54
+ if (validated.kind === "error") {
55
+ log.warn("scheduler-tasks", "create: validation failed", { error: validated.error });
56
+ badRequest(res, validated.error);
57
+ return;
58
+ }
62
59
  const task = await withUserTaskLock(async (tasks) => ({
63
60
  tasks: [...tasks, validated.task],
64
61
  result: validated.task,
65
62
  }));
66
63
  log.info("scheduler-tasks", "create: ok", { id: task.id, name: task.name });
67
64
  res.status(201).json({ task });
68
- } catch (err) {
69
- log.error("scheduler-tasks", "create: failed", {
70
- error: String(err),
71
- });
72
- serverError(res, "Failed to create task");
73
- }
74
- });
65
+ }),
66
+ );
75
67
 
76
68
  // ── Update user task ────────────────────────────────────────────
77
69
 
78
- bindRoute(router, API_ROUTES.scheduler.taskUpdate, async (req: Request<{ id: string }>, res: Response) => {
79
- const { id: taskId } = req.params;
80
- log.info("scheduler-tasks", "update: start", { taskId });
81
- try {
82
- const updated = await withUserTaskLock(async (tasks) => {
83
- const result = applyUpdate(tasks, taskId, req.body);
84
- if (result.kind === "error") {
85
- throw new Error(result.error);
70
+ bindRoute(
71
+ router,
72
+ API_ROUTES.scheduler.taskUpdate,
73
+ asyncHandler<Request<{ id: string }>, Response>("scheduler-tasks", "Failed to update task", async (req, res) => {
74
+ const { id: taskId } = req.params;
75
+ log.info("scheduler-tasks", "update: start", { taskId });
76
+ try {
77
+ const updated = await withUserTaskLock(async (tasks) => {
78
+ const result = applyUpdate(tasks, taskId, req.body);
79
+ if (result.kind === "error") {
80
+ throw new Error(result.error);
81
+ }
82
+ const task = result.tasks.find((taskItem) => taskItem.id === taskId);
83
+ return { tasks: result.tasks, result: task };
84
+ });
85
+ log.info("scheduler-tasks", "update: ok", { taskId });
86
+ res.json({ task: updated });
87
+ } catch (err) {
88
+ // Domain-shaped errors → 404; everything else rethrows for the
89
+ // asyncHandler wrapper to surface as 500.
90
+ const msg = errorMessage(err);
91
+ if (msg.startsWith("task not found") || msg.startsWith("request body")) {
92
+ log.warn("scheduler-tasks", "update: validation failed", { taskId, reason: msg });
93
+ notFound(res, msg);
94
+ return;
86
95
  }
87
- const task = result.tasks.find((taskItem) => taskItem.id === taskId);
88
- return { tasks: result.tasks, result: task };
89
- });
90
- log.info("scheduler-tasks", "update: ok", { taskId });
91
- res.json({ task: updated });
92
- } catch (err) {
93
- const msg = errorMessage(err);
94
- if (msg.startsWith("task not found") || msg.startsWith("request body")) {
95
- log.warn("scheduler-tasks", "update: validation failed", { taskId, reason: msg });
96
- notFound(res, msg);
97
- return;
96
+ throw err;
98
97
  }
99
- log.error("scheduler-tasks", "update: failed", { taskId, error: msg });
100
- serverError(res, "Failed to update task");
101
- }
102
- });
98
+ }),
99
+ );
103
100
 
104
101
  // ── Delete user task ────────────────────────────────────────────
105
102
 
106
- bindRoute(router, API_ROUTES.scheduler.taskDelete, async (req: Request<{ id: string }>, res: Response) => {
107
- const { id: taskId } = req.params;
108
- log.info("scheduler-tasks", "delete: start", { taskId });
109
- try {
110
- await withUserTaskLock(async (tasks) => {
111
- const index = tasks.findIndex((task) => task.id === taskId);
112
- if (index === -1) throw new Error(`task not found: ${taskId}`);
113
- const next = tasks.filter((task) => task.id !== taskId);
114
- return { tasks: next, result: undefined };
115
- });
116
- log.info("scheduler-tasks", "delete: ok", { taskId });
117
- res.json({ deleted: taskId });
118
- } catch (err) {
119
- const msg = errorMessage(err);
120
- if (msg.startsWith("task not found")) {
121
- log.warn("scheduler-tasks", "delete: not found", { taskId });
122
- notFound(res, msg);
123
- return;
103
+ bindRoute(
104
+ router,
105
+ API_ROUTES.scheduler.taskDelete,
106
+ asyncHandler<Request<{ id: string }>, Response>("scheduler-tasks", "Failed to delete task", async (req, res) => {
107
+ const { id: taskId } = req.params;
108
+ log.info("scheduler-tasks", "delete: start", { taskId });
109
+ try {
110
+ await withUserTaskLock(async (tasks) => {
111
+ const index = tasks.findIndex((task) => task.id === taskId);
112
+ if (index === -1) throw new Error(`task not found: ${taskId}`);
113
+ const next = tasks.filter((task) => task.id !== taskId);
114
+ return { tasks: next, result: undefined };
115
+ });
116
+ log.info("scheduler-tasks", "delete: ok", { taskId });
117
+ res.json({ deleted: taskId });
118
+ } catch (err) {
119
+ const msg = errorMessage(err);
120
+ if (msg.startsWith("task not found")) {
121
+ log.warn("scheduler-tasks", "delete: not found", { taskId });
122
+ notFound(res, msg);
123
+ return;
124
+ }
125
+ throw err;
124
126
  }
125
- log.error("scheduler-tasks", "delete: failed", { taskId, error: msg });
126
- serverError(res, "Failed to delete task");
127
- }
128
- });
127
+ }),
128
+ );
129
129
 
130
130
  // ── Manual trigger ──────────────────────────────────────────────
131
131
 
@@ -177,25 +177,28 @@ interface LogQuery {
177
177
  limit?: string;
178
178
  }
179
179
 
180
- bindRoute(router, API_ROUTES.scheduler.logs, async (req: Request<object, unknown, object, LogQuery>, res: Response<{ logs: TaskLogEntry[] }>) => {
181
- const MAX_LIMIT = 500;
182
- const rawLimitStr = getOptionalStringQuery(req, "limit");
183
- const rawLimit = rawLimitStr ? parseInt(rawLimitStr, 10) : undefined;
184
- const limit = rawLimit !== undefined && Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, MAX_LIMIT) : undefined;
185
- const taskId = getOptionalStringQuery(req, "taskId");
186
- log.info("scheduler-tasks", "logs: start", { taskId, limit });
187
- try {
188
- const logs = await getSchedulerLogs({
189
- since: getOptionalStringQuery(req, "since"),
190
- taskId,
191
- limit,
192
- });
193
- log.info("scheduler-tasks", "logs: ok", { entries: logs.length, taskId });
194
- res.json({ logs });
195
- } catch (err) {
196
- log.error("scheduler-tasks", "logs: failed", { taskId, error: errorMessage(err) });
197
- serverError(res, "Failed to read scheduler logs");
198
- }
199
- });
180
+ bindRoute(
181
+ router,
182
+ API_ROUTES.scheduler.logs,
183
+ asyncHandler<Request<object, unknown, object, LogQuery>, Response<{ logs: TaskLogEntry[] }>>(
184
+ "scheduler-tasks",
185
+ "Failed to read scheduler logs",
186
+ async (req, res) => {
187
+ const MAX_LIMIT = 500;
188
+ const rawLimitStr = getOptionalStringQuery(req, "limit");
189
+ const rawLimit = rawLimitStr ? parseInt(rawLimitStr, 10) : undefined;
190
+ const limit = rawLimit !== undefined && Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, MAX_LIMIT) : undefined;
191
+ const taskId = getOptionalStringQuery(req, "taskId");
192
+ log.info("scheduler-tasks", "logs: start", { taskId, limit });
193
+ const logs = await getSchedulerLogs({
194
+ since: getOptionalStringQuery(req, "since"),
195
+ taskId,
196
+ limit,
197
+ });
198
+ log.info("scheduler-tasks", "logs: ok", { entries: logs.length, taskId });
199
+ res.json({ logs });
200
+ },
201
+ ),
202
+ );
200
203
 
201
204
  export default router;
@@ -25,6 +25,7 @@ import { ONE_DAY_MS } from "../../utils/time.js";
25
25
  import { encodeCursor, parseCursor, sessionChangeMs } from "./sessionsCursor.js";
26
26
  import { errorMessage } from "../../utils/errors.js";
27
27
  import { log } from "../../system/logger/index.js";
28
+ import { asyncHandler } from "../../utils/asyncHandler.js";
28
29
 
29
30
  interface SessionMeta {
30
31
  roleId: string;
@@ -355,14 +356,13 @@ router.post(API_ROUTES.sessions.markRead, async (req: Request<SessionIdParams>,
355
356
  // Toggle the user-set bookmark flag on a session's meta sidecar.
356
357
  router.post(
357
358
  API_ROUTES.sessions.bookmark,
358
- async (
359
- req: Request<SessionIdParams, { ok: boolean } | SessionErrorResponse, { bookmarked: boolean }>,
360
- res: Response<{ ok: boolean } | SessionErrorResponse>,
361
- ) => {
362
- const { id: sessionId } = req.params;
363
- const bookmarked = Boolean(req.body?.bookmarked);
364
- log.info("sessions", "bookmark: start", { sessionId, bookmarked });
365
- try {
359
+ asyncHandler<Request<SessionIdParams, { ok: boolean } | SessionErrorResponse, { bookmarked: boolean }>, Response<{ ok: boolean } | SessionErrorResponse>>(
360
+ "sessions",
361
+ "Failed to update bookmark",
362
+ async (req, res) => {
363
+ const { id: sessionId } = req.params;
364
+ const bookmarked = Boolean(req.body?.bookmarked);
365
+ log.info("sessions", "bookmark: start", { sessionId, bookmarked });
366
366
  await updateIsBookmarked(sessionId, bookmarked);
367
367
  // Meta-mtime bumps on the write — cursor diff will pick up the
368
368
  // change on the next refetch — but every other tab also needs
@@ -370,11 +370,8 @@ router.post(
370
370
  publishSessionsChanged();
371
371
  log.info("sessions", "bookmark: ok", { sessionId, bookmarked });
372
372
  res.json({ ok: true });
373
- } catch (err) {
374
- log.error("sessions", "bookmark: threw", { sessionId, error: errorMessage(err) });
375
- res.status(500).json({ error: "Failed to update bookmark" });
376
- }
377
- },
373
+ },
374
+ ),
378
375
  );
379
376
 
380
377
  // Hard-delete a session: remove the jsonl, meta sidecar, AND the
@@ -395,24 +392,22 @@ router.post(
395
392
  // 3. Only after disk is clean do we evict from the store and fire
396
393
  // `notifySessionsChanged({ deletedIds })`. Now the broadcast is
397
394
  // a truthful statement.
398
- router.delete(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res: Response<{ ok: boolean } | SessionErrorResponse>) => {
399
- const { id: sessionId } = req.params;
400
- log.info("sessions", "delete: start", { sessionId });
401
- if (getSession(sessionId)?.isRunning) {
402
- log.warn("sessions", "delete: refused — session running", { sessionId });
403
- res.status(409).json({ error: "Session is running. Cancel the run before deleting." });
404
- return;
405
- }
406
- try {
395
+ router.delete(
396
+ API_ROUTES.sessions.detail,
397
+ asyncHandler<Request<SessionIdParams>, Response<{ ok: boolean } | SessionErrorResponse>>("sessions", "Failed to delete session", async (req, res) => {
398
+ const { id: sessionId } = req.params;
399
+ log.info("sessions", "delete: start", { sessionId });
400
+ if (getSession(sessionId)?.isRunning) {
401
+ log.warn("sessions", "delete: refused — session running", { sessionId });
402
+ res.status(409).json({ error: "Session is running. Cancel the run before deleting." });
403
+ return;
404
+ }
407
405
  await deleteSessionFiles(sessionId);
408
406
  await removeSessionFromIndex(workspacePath, sessionId);
409
407
  evictSession(sessionId);
410
408
  log.info("sessions", "delete: ok", { sessionId });
411
409
  res.json({ ok: true });
412
- } catch (err) {
413
- log.error("sessions", "delete: threw", { sessionId, error: errorMessage(err) });
414
- res.status(500).json({ error: "Failed to delete session" });
415
- }
416
- });
410
+ }),
411
+ );
417
412
 
418
413
  export default router;
@@ -36,6 +36,7 @@ import { badRequest, conflict, sendError, serverError } from "../../utils/httpEr
36
36
  import { errorMessage } from "../../utils/errors.js";
37
37
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
38
38
  import { bindRoute } from "../../utils/router.js";
39
+ import { asyncHandler } from "../../utils/asyncHandler.js";
39
40
  import { isNonEmptyString, isRecord } from "../../utils/types.js";
40
41
 
41
42
  const router = Router();
@@ -56,15 +57,14 @@ interface ErrorResponse {
56
57
  error: string;
57
58
  }
58
59
 
59
- bindRoute(router, API_ROUTES.sources.list, async (_req: Request, res: Response<ListSourcesResponse | ErrorResponse>) => {
60
- try {
60
+ bindRoute(
61
+ router,
62
+ API_ROUTES.sources.list,
63
+ asyncHandler<Request, Response<ListSourcesResponse | ErrorResponse>>("sources", "failed to list sources", async (_req, res) => {
61
64
  const sources = await listSources(workspacePath);
62
65
  res.json({ sources });
63
- } catch (err) {
64
- log.warn("sources", "list failed", { error: String(err) });
65
- serverError(res, errorMessage(err, "unknown error"));
66
- }
67
- });
66
+ }),
67
+ );
68
68
 
69
69
  // --- POST /api/sources --------------------------------------------------
70
70
 
@@ -164,13 +164,15 @@ interface RebuildBody {
164
164
  scheduleType?: unknown;
165
165
  }
166
166
 
167
- bindRoute(router, API_ROUTES.sources.rebuild, async (req: Request<object, unknown, RebuildBody>, res: Response<ErrorResponse | Record<string, unknown>>) => {
168
- const scheduleType = validateSchedule(req.body?.scheduleType, "daily");
169
- if (!scheduleType) {
170
- badRequest(res, `scheduleType must be one of: ${[...SOURCE_SCHEDULES].join(", ")}`);
171
- return;
172
- }
173
- try {
167
+ bindRoute(
168
+ router,
169
+ API_ROUTES.sources.rebuild,
170
+ asyncHandler<Request<object, unknown, RebuildBody>, Response<ErrorResponse | Record<string, unknown>>>("sources", "rebuild failed", async (req, res) => {
171
+ const scheduleType = validateSchedule(req.body?.scheduleType, "daily");
172
+ if (!scheduleType) {
173
+ badRequest(res, `scheduleType must be one of: ${[...SOURCE_SCHEDULES].join(", ")}`);
174
+ return;
175
+ }
174
176
  log.info("sources", "manual rebuild triggered", { scheduleType });
175
177
  const result = await runSourcesPipeline({
176
178
  workspaceRoot: workspacePath,
@@ -196,11 +198,8 @@ bindRoute(router, API_ROUTES.sources.rebuild, async (req: Request<object, unknow
196
198
  archiveErrors: result.archiveErrors,
197
199
  isoDate: result.isoDate,
198
200
  });
199
- } catch (err) {
200
- log.warn("sources", "rebuild failed", { error: String(err) });
201
- serverError(res, errorMessage(err, "rebuild failed"));
202
- }
203
- });
201
+ }),
202
+ );
204
203
 
205
204
  // --- POST /api/sources/manage -------------------------------------------
206
205
  //
@@ -244,31 +243,34 @@ interface ManageSourceSuccess {
244
243
 
245
244
  const MANAGE_ACTIONS = new Set(["list", "register", "remove", "rebuild"]);
246
245
 
247
- bindRoute(router, API_ROUTES.sources.manage, async (req: Request<object, unknown, ManageSourceBody>, res: Response<ManageSourceSuccess | ErrorResponse>) => {
248
- const action = req.body?.action;
249
- if (typeof action !== "string" || !MANAGE_ACTIONS.has(action)) {
250
- badRequest(res, `action must be one of: ${[...MANAGE_ACTIONS].join(", ")}`);
251
- return;
252
- }
253
- try {
254
- switch (action) {
255
- case "list":
256
- await respondWithList(res, "Loaded source registry.");
257
- return;
258
- case "register":
259
- await handleRegister(req.body, res);
260
- return;
261
- case "remove":
262
- await handleRemove(req.body, res);
246
+ bindRoute(
247
+ router,
248
+ API_ROUTES.sources.manage,
249
+ asyncHandler<Request<object, unknown, ManageSourceBody>, Response<ManageSourceSuccess | ErrorResponse>>(
250
+ "sources",
251
+ "manageSource dispatch failed",
252
+ async (req, res) => {
253
+ const action = req.body?.action;
254
+ if (typeof action !== "string" || !MANAGE_ACTIONS.has(action)) {
255
+ badRequest(res, `action must be one of: ${[...MANAGE_ACTIONS].join(", ")}`);
263
256
  return;
264
- case "rebuild":
265
- await handleRebuild(res);
266
- }
267
- } catch (err) {
268
- log.warn("sources", "manage failed", { action, error: String(err) });
269
- serverError(res, errorMessage(err, "manage failed"));
270
- }
271
- });
257
+ }
258
+ switch (action) {
259
+ case "list":
260
+ await respondWithList(res, "Loaded source registry.");
261
+ return;
262
+ case "register":
263
+ await handleRegister(req.body, res);
264
+ return;
265
+ case "remove":
266
+ await handleRemove(req.body, res);
267
+ return;
268
+ case "rebuild":
269
+ await handleRebuild(res);
270
+ }
271
+ },
272
+ ),
273
+ );
272
274
 
273
275
  async function respondWithList(res: Response<ManageSourceSuccess | ErrorResponse>, message: string, extra: Partial<ManageSourceData> = {}): Promise<void> {
274
276
  const sources = await listSources(workspacePath);
@@ -7,9 +7,9 @@
7
7
  // itself, so undo stays cheap).
8
8
  //
9
9
  // Path safety: both `:slug` and `:stamp` are validated *before*
10
- // they are joined with the workspace root. The slug check matches
11
- // `wiki-pages/io.ts`'s `isSafeSlug`; the stamp check is the
12
- // `FILENAME_RE` shape exposed via `isSafeStamp`.
10
+ // they are joined with the workspace root. The slug check is the
11
+ // shared `isSafeSlug` from `src/lib/wiki-page/slug.ts`; the stamp
12
+ // check is the `FILENAME_RE` shape exposed via `isSafeStamp`.
13
13
 
14
14
  import { Router, type Request, type Response } from "express";
15
15
  import path from "node:path";
@@ -24,20 +24,11 @@ import { readTextOrNull } from "../../../utils/files/safe.js";
24
24
  import { workspacePath } from "../../../workspace/workspace.js";
25
25
  import { pushToolResult } from "../../../events/session-store/index.js";
26
26
  import { log } from "../../../system/logger/index.js";
27
+ import { errorMessage } from "../../../utils/errors.js";
28
+ import { isSafeSlug } from "../../../../src/lib/wiki-page/slug.js";
27
29
 
28
30
  const router = Router();
29
31
 
30
- // Mirrors `isSafeSlug` from wiki-pages/io.ts (kept independent so
31
- // the route layer doesn't import the helper through a circular
32
- // dependency — io.ts already imports snapshot.ts).
33
- function isSafeSlug(slug: string): boolean {
34
- if (slug.length === 0) return false;
35
- if (slug === "." || slug === "..") return false;
36
- if (slug.includes("/") || slug.includes("\\")) return false;
37
- if (slug.includes("\0")) return false;
38
- return true;
39
- }
40
-
41
32
  // Restore is a write under the user's workspace; record a short
42
33
  // reason on the new snapshot so the history reads "Restored from
43
34
  // 2026-04-28T01-23-45-789Z" rather than an empty cell. Editor stays
@@ -250,7 +241,7 @@ router.post("/internal/snapshot", async (req: Request<object, unknown, InternalS
250
241
  } catch (err) {
251
242
  log.warn("wiki", "page-edit toolResult publish failed", {
252
243
  slug,
253
- error: err instanceof Error ? err.message : String(err),
244
+ error: errorMessage(err),
254
245
  });
255
246
  }
256
247
  }