mulmoclaude 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
  3. package/client/assets/index-7QnbsZZr.css +2 -0
  4. package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
  5. package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
  6. package/client/index.html +3 -3
  7. package/package.json +7 -6
  8. package/server/agent/attachmentConverter.ts +0 -25
  9. package/server/agent/mcp-server.ts +27 -8
  10. package/server/api/auth/viewToken.ts +16 -8
  11. package/server/api/routes/agent.ts +49 -11
  12. package/server/api/routes/collections.ts +274 -102
  13. package/server/api/routes/files.ts +57 -54
  14. package/server/api/routes/mulmo-script.ts +257 -895
  15. package/server/api/routes/wiki/history.ts +17 -19
  16. package/server/build/dispatcher.mjs +2 -2
  17. package/server/events/collection-change.ts +1 -5
  18. package/server/events/file-change.ts +1 -6
  19. package/server/events/pub-sub/index.ts +15 -2
  20. package/server/events/relay-client.ts +6 -1
  21. package/server/events/session-store/index.ts +11 -2
  22. package/server/index.ts +51 -78
  23. package/server/plugins/diagnostics.ts +0 -6
  24. package/server/plugins/mulmoscript-server.ts +58 -0
  25. package/server/prompts/system/system.md +2 -3
  26. package/server/remoteHost/handlers/getCollection.ts +5 -4
  27. package/server/remoteHost/handlers/getFeed.ts +10 -5
  28. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  29. package/server/remoteHost/handlers/index.ts +3 -1
  30. package/server/services/translation/index.ts +10 -3
  31. package/server/utils/errors.ts +7 -21
  32. package/server/utils/files/html-store.ts +2 -16
  33. package/server/utils/files/journal-io.ts +0 -12
  34. package/server/utils/files/plugins-io.ts +2 -11
  35. package/server/utils/files/safe.ts +39 -0
  36. package/server/utils/httpError.ts +6 -0
  37. package/server/utils/text.ts +7 -23
  38. package/server/utils/time.ts +0 -3
  39. package/server/workspace/collections/index.ts +6 -4
  40. package/server/workspace/collections/remoteView.ts +31 -22
  41. package/server/workspace/collections/watcher.ts +1 -0
  42. package/server/workspace/custom-dirs.ts +16 -6
  43. package/server/workspace/journal/state.ts +1 -9
  44. package/server/workspace/memory/topic-index-hook.ts +0 -9
  45. package/server/workspace/memory/topic-io.ts +0 -4
  46. package/server/workspace/reference-dirs.ts +8 -3
  47. package/server/workspace/skills/external/catalog.ts +24 -13
  48. package/server/workspace/skills/writer.ts +20 -16
  49. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  50. package/src/components/SettingsGoogleTab.vue +4 -1
  51. package/src/components/StackView.vue +12 -1
  52. package/src/composables/collections/uiHost.ts +2 -1
  53. package/src/composables/useChatScroll.ts +18 -6
  54. package/src/composables/useFileDropZone.ts +3 -15
  55. package/src/composables/useFileSelection.ts +4 -1
  56. package/src/composables/useStickToBottom.ts +51 -0
  57. package/src/config/apiRoutes.ts +20 -0
  58. package/src/config/roles.ts +0 -4
  59. package/src/lang/de.ts +0 -30
  60. package/src/lang/en.ts +0 -30
  61. package/src/lang/es.ts +0 -30
  62. package/src/lang/fr.ts +0 -30
  63. package/src/lang/ja.ts +0 -30
  64. package/src/lang/ko.ts +0 -30
  65. package/src/lang/pt-BR.ts +0 -30
  66. package/src/lang/zh.ts +0 -30
  67. package/src/plugins/accounting/index.ts +0 -2
  68. package/src/plugins/api.ts +0 -6
  69. package/src/plugins/canvas/index.ts +0 -2
  70. package/src/plugins/chart/index.ts +0 -2
  71. package/src/plugins/editImages/index.ts +0 -2
  72. package/src/plugins/generateImage/index.ts +0 -2
  73. package/src/plugins/manageSkills/index.ts +0 -2
  74. package/src/plugins/markdown/index.ts +0 -2
  75. package/src/plugins/photoLocations/index.ts +0 -2
  76. package/src/plugins/presentCollection/index.ts +0 -2
  77. package/src/plugins/presentForm/index.ts +0 -2
  78. package/src/plugins/presentHtml/index.ts +0 -2
  79. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  80. package/src/plugins/presentMulmoScript/index.ts +62 -12
  81. package/src/plugins/presentSVG/index.ts +0 -2
  82. package/src/plugins/skill/index.ts +0 -3
  83. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  84. package/src/plugins/spreadsheet/index.ts +0 -2
  85. package/src/plugins/textResponse/index.ts +0 -3
  86. package/src/plugins/wiki/index.ts +0 -2
  87. package/src/types/notification.ts +0 -10
  88. package/src/utils/api.ts +0 -4
  89. package/src/utils/dom/scrollable.ts +15 -0
  90. package/src/utils/errors.ts +7 -38
  91. package/client/assets/index-D5nJbMO1.css +0 -2
  92. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  93. package/server/utils/mulmoErrorCapture.ts +0 -93
  94. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  95. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  96. package/src/plugins/presentMulmoScript/helpers.ts +0 -206
@@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto";
17
17
  import { TOOL_NAMES } from "../../../../src/config/toolNames.js";
18
18
  import { hasMeaningfulChange, writeWikiPage } from "../../../workspace/wiki-pages/io.js";
19
19
  import { WORKSPACE_DIRS } from "../../../workspace/paths.js";
20
- import { isSafeStamp, listSnapshots, readSnapshot, stripSnapshotMeta } from "../../../workspace/wiki-pages/snapshot.js";
20
+ import { isSafeStamp, listSnapshots, readSnapshot, stripSnapshotMeta, type SnapshotContent } from "../../../workspace/wiki-pages/snapshot.js";
21
21
  import { mergeFrontmatter, serializeWithFrontmatter } from "../../../utils/markdown/frontmatter.js";
22
22
  import { badRequest, notFound } from "../../../utils/httpError.js";
23
23
  import { readTextOrNull } from "../../../utils/files/safe.js";
@@ -66,39 +66,37 @@ router.get("/pages/:slug/history", async (req: Request<{ slug: string }>, res: R
66
66
  res.json({ slug, snapshots });
67
67
  });
68
68
 
69
- router.get("/pages/:slug/history/:stamp", async (req: Request<{ slug: string; stamp: string }>, res: Response) => {
69
+ // Validate `:slug`/`:stamp` and load the snapshot, or respond (400 for
70
+ // an unsafe param, 404 for a missing snapshot) and return null. Shared
71
+ // by the read and restore routes so the guard lives in one place.
72
+ async function resolveSnapshotOr4xx(req: Request<{ slug: string; stamp: string }>, res: Response): Promise<SnapshotContent | null> {
70
73
  const { slug, stamp } = req.params;
71
74
  if (!isSafeSlug(slug)) {
72
75
  badRequest(res, "Unsafe slug");
73
- return;
76
+ return null;
74
77
  }
75
78
  if (!isSafeStamp(stamp)) {
76
79
  badRequest(res, "Unsafe stamp");
77
- return;
80
+ return null;
78
81
  }
79
82
  const snapshot = await readSnapshot(slug, stamp);
80
83
  if (snapshot === null) {
81
84
  notFound(res, `snapshot not found: ${slug}/${stamp}`);
82
- return;
85
+ return null;
83
86
  }
84
- res.json({ slug, snapshot });
87
+ return snapshot;
88
+ }
89
+
90
+ router.get("/pages/:slug/history/:stamp", async (req: Request<{ slug: string; stamp: string }>, res: Response) => {
91
+ const snapshot = await resolveSnapshotOr4xx(req, res);
92
+ if (!snapshot) return;
93
+ res.json({ slug: req.params.slug, snapshot });
85
94
  });
86
95
 
87
96
  router.post("/pages/:slug/history/:stamp/restore", async (req: Request<{ slug: string; stamp: string }>, res: Response) => {
97
+ const snapshot = await resolveSnapshotOr4xx(req, res);
98
+ if (!snapshot) return;
88
99
  const { slug, stamp } = req.params;
89
- if (!isSafeSlug(slug)) {
90
- badRequest(res, "Unsafe slug");
91
- return;
92
- }
93
- if (!isSafeStamp(stamp)) {
94
- badRequest(res, "Unsafe stamp");
95
- return;
96
- }
97
- const snapshot = await readSnapshot(slug, stamp);
98
- if (snapshot === null) {
99
- notFound(res, `snapshot not found: ${slug}/${stamp}`);
100
- return;
101
- }
102
100
 
103
101
  // Strip `_snapshot_*` keys before writing — they describe the
104
102
  // *original* save event and would be misleading on the restored
@@ -126,7 +126,7 @@ async function handleConfigRefresh(payload) {
126
126
  await safePost(req);
127
127
  }
128
128
 
129
- // packages/core/dist/collection/paths.js
129
+ // packages/core/dist/templatePath-k_WNbL_Q.js
130
130
  var TEMPLATES_PREFIX = "templates/";
131
131
  function isSafeTemplatePath(value) {
132
132
  if (value.length === 0 || value.includes("\\") || value.startsWith("/")) return false;
@@ -201,7 +201,7 @@ function mirrorSkillDelete(workspaceRoot2, slug) {
201
201
  return { dest };
202
202
  }
203
203
 
204
- // server/utils/errors.ts
204
+ // packages/core/dist/errors-eid6Mes3.js
205
205
  function errorMessage(err, fallback) {
206
206
  if (err instanceof Error) return err.message;
207
207
  if (err !== null && typeof err === "object") {
@@ -24,6 +24,7 @@
24
24
  // package catches up, but the launcher boots. Matches the feature's
25
25
  // "optional everywhere" design (the View-side `subscribeChanges` is optional too).
26
26
 
27
+ // eslint-disable-next-line no-restricted-imports -- namespace import is the feature-detect described above; only `setCollectionChangePublisher` is accessed, never the restricted raw io readers
27
28
  import * as collectionPlugin from "@mulmoclaude/core/collection/server";
28
29
  import type { CollectionChangePayload } from "@mulmoclaude/core/collection/server";
29
30
  import { collectionChannel, type CollectionChannelPayload } from "../../src/config/pubsubChannels.js";
@@ -63,8 +64,3 @@ export function initCollectionChangePublisher(instance: IPubSub): void {
63
64
  }
64
65
  });
65
66
  }
66
-
67
- /** Detach the publisher (test teardown). No-op against an older package. */
68
- export function _resetCollectionChangePublisherForTesting(): void {
69
- resolveSetPublisher()?.(null);
70
- }
@@ -11,7 +11,7 @@
11
11
  // publisher; we hand those to the package as the primary channel + normaliser.
12
12
 
13
13
  import type { IPubSub } from "./pub-sub/index.js";
14
- import { configureFileChangePublisher, publishFileChange, resetFileChangePublisher } from "@mulmoclaude/core/file-change";
14
+ import { configureFileChangePublisher, publishFileChange } from "@mulmoclaude/core/file-change";
15
15
  import { fileChannel, toPosixWorkspacePath } from "../../src/config/pubsubChannels.js";
16
16
  import { isMarkdownPath } from "../utils/files/markdown-store.js";
17
17
  import { isHtmlPath } from "../utils/files/html-store.js";
@@ -48,8 +48,3 @@ export function initFileChangePublisher(instance: IPubSub): void {
48
48
  warn: (message, data) => log.warn("file-change", message, data),
49
49
  });
50
50
  }
51
-
52
- /** Test-only — clear the module singleton so each test starts clean. */
53
- export function _resetFileChangePublisherForTesting(): void {
54
- resetFileChangePublisher();
55
- }
@@ -1,11 +1,19 @@
1
1
  import http from "http";
2
2
  import { Server as IOServer } from "socket.io";
3
3
 
4
+ import { log } from "../../system/logger/index.js";
5
+
4
6
  export interface IPubSub {
5
7
  /** Publish data to all clients subscribed to this channel. */
6
8
  publish: (channel: string, data: unknown) => void;
7
9
  }
8
10
 
11
+ const logRoomError =
12
+ (action: string, channel: string) =>
13
+ (err: unknown): void => {
14
+ log.warn("pubsub", `socket room ${action} failed`, { channel, error: String(err) });
15
+ };
16
+
9
17
  // Channel names are treated as socket.io rooms — one room per
10
18
  // channel. Subscribe/unsubscribe is plain `socket.join` /
11
19
  // `socket.leave`. Publish broadcasts to the room. Reconnect /
@@ -29,11 +37,16 @@ export function createPubSub(server: http.Server): IPubSub {
29
37
  });
30
38
 
31
39
  ioServer.on("connection", (socket) => {
40
+ // `join`/`leave` return `void | Promise<void>`: the in-memory adapter runs
41
+ // synchronously and returns undefined, while a clustered adapter goes over
42
+ // the wire. Nothing here depends on the result, but the promise branch
43
+ // still needs a terminal handler — a bare `void` would turn an adapter
44
+ // failure into an unhandled rejection. `Promise.resolve` normalises both.
32
45
  socket.on("subscribe", (channel: unknown) => {
33
- if (typeof channel === "string") socket.join(channel);
46
+ if (typeof channel === "string") Promise.resolve(socket.join(channel)).catch(logRoomError("subscribe", channel));
34
47
  });
35
48
  socket.on("unsubscribe", (channel: unknown) => {
36
- if (typeof channel === "string") socket.leave(channel);
49
+ if (typeof channel === "string") Promise.resolve(socket.leave(channel)).catch(logRoomError("unsubscribe", channel));
37
50
  });
38
51
  });
39
52
 
@@ -277,7 +277,12 @@ function attachSocketHandlers(socket: WebSocket, deps: AttachSocketHandlersDeps)
277
277
  });
278
278
 
279
279
  socket.on("message", (data) => {
280
- handleRelayMessage(String(data), { relay, logger, sendResponse: queue.send });
280
+ // One bad message must not take the socket down: `handleRelayMessage`
281
+ // already logs the failures it anticipates, so anything reaching here is
282
+ // unexpected and only needs to be recorded, not propagated.
283
+ void handleRelayMessage(String(data), { relay, logger, sendResponse: queue.send }).catch((err: unknown) => {
284
+ logger.error(LOG_PREFIX, "relay message handler threw", { error: String(err) });
285
+ });
281
286
  });
282
287
 
283
288
  socket.on("close", (code, reason) => {
@@ -183,7 +183,10 @@ export function endRun(chatSessionId: string): void {
183
183
  session.statusMessage = "";
184
184
  session.abortRun = undefined;
185
185
  session.updatedAt = new Date().toISOString();
186
- persistHasUnread(chatSessionId, true);
186
+ // Same fire-and-forget contract as the other unread writes: the in-memory
187
+ // flag above is what the UI reads, so a failed persist must not break the
188
+ // finish path.
189
+ persistHasUnread(chatSessionId, true).catch(() => {});
187
190
  publishToSessionChannel(chatSessionId, {
188
191
  type: EVENT_TYPES.sessionFinished,
189
192
  });
@@ -264,7 +267,13 @@ export function pushSessionEvent(chatSessionId: string, event: Record<string, un
264
267
  // notify.
265
268
  persistHasUnread(chatSessionId, true)
266
269
  .catch(() => {})
267
- .then(() => notifySessionsChanged());
270
+ .then(() => notifySessionsChanged())
271
+ // Terminal handler: the `.catch` above only covers the persist, so a throw
272
+ // out of `notifySessionsChanged` would still escape. Same trade as the
273
+ // agent route (see its BEHAVIOUR NOTE) — this used to bounce the whole
274
+ // server through the process-level `unhandledRejection` handler; now an
275
+ // unread-badge glitch stays a glitch. Rethrow here to go back to fail-fast.
276
+ .catch((err: unknown) => log.warn("session-store", "unread notify failed", { error: String(err) }));
268
277
  }
269
278
 
270
279
  /**
package/server/index.ts CHANGED
@@ -75,6 +75,10 @@ import { readSessionJsonl, readSessionMeta } from "./utils/files/session-io.js";
75
75
  import { resolveBridgeSessionRole } from "./api/bridge/sessionRole.js";
76
76
  import { onSessionEvent, initSessionStore } from "./events/session-store/index.js";
77
77
  import { initFileChangePublisher } from "./events/file-change.js";
78
+ // Importing also binds the shared mulmoScript server ops to this host's
79
+ // backend and registers the built-in "mulmoScript" dispatch handler (side
80
+ // effect at module load — plans/feat-mulmoscript-plugin.md phase 3).
81
+ import { initMulmoScriptGenerationPublisher } from "./plugins/mulmoscript-server.js";
78
82
  import { initCollectionChangePublisher } from "./events/collection-change.js";
79
83
  import { getRole, loadAllRoles } from "./workspace/roles.js";
80
84
  import { discoverSkills } from "./workspace/skills/index.js";
@@ -93,13 +97,13 @@ import { migrateCookingRecipesFromPlugin } from "./workspace/cooking-recipes/mig
93
97
  import { env, isAblated, isGeminiAvailable } from "./system/env.js";
94
98
  import { buildSandboxStatus } from "./api/sandboxStatus.js";
95
99
  import { existsSync, readFileSync } from "fs";
96
- import { realpath as fsRealpath } from "fs/promises";
97
- import { containsDotfileSegment, resolveWithinRoot } from "./utils/files/safe.js";
100
+ import { makeCachedRealpath, resolveArtifactRequestPath } from "./utils/files/safe.js";
98
101
  import { cpus, loadavg } from "os";
99
102
  import { isDockerAvailable, ensureSandboxImage } from "./system/docker.js";
100
103
  import { maybeRunJournal } from "./workspace/journal/index.js";
101
104
  import { backfillAllSessions } from "./workspace/chat-index/index.js";
102
105
  import { feedRefreshTaskDef } from "@mulmoclaude/core/feeds/server";
106
+ import { googleCalendarSyncTaskDef } from "@mulmoclaude/core/google";
103
107
  import { configureFeeds } from "./workspace/feeds/configure.js";
104
108
  import { createPubSub } from "./events/pub-sub/index.js";
105
109
  import { PUBSUB_CHANNELS } from "../src/config/pubsubChannels.js";
@@ -351,17 +355,7 @@ app.use("/api", (req, res, next) => {
351
355
  // 3. `dotfiles: deny` + `fallthrough: false` on `express.static`
352
356
  // itself, plus its built-in `..` normalize for path traversal.
353
357
  const IMAGE_EXT_RE = /\.(png|jpe?g|webp|gif|svg|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
354
- let imagesDirReal: string | null = null;
355
- async function getImagesDirReal(): Promise<string | null> {
356
- if (imagesDirReal) return imagesDirReal;
357
- try {
358
- imagesDirReal = await fsRealpath(WORKSPACE_PATHS.images);
359
- return imagesDirReal;
360
- } catch {
361
- // Dir not yet materialised (fresh workspace, no image saved).
362
- return null;
363
- }
364
- }
358
+ const getImagesDirReal = makeCachedRealpath(WORKSPACE_PATHS.images);
365
359
  app.use(
366
360
  "/artifacts/images",
367
361
  async (req, res, next) => {
@@ -374,17 +368,9 @@ app.use(
374
368
  res.status(404).end();
375
369
  return;
376
370
  }
377
- let relPath: string;
378
- try {
379
- // decodeURIComponent throws URIError on malformed escapes
380
- // (`%ZZ`, stray `%`). Fail closed so a junk URL returns 404
381
- // instead of bubbling a 500 out of the express error chain.
382
- relPath = decodeURIComponent(req.path.replace(/^\//, ""));
383
- } catch {
384
- res.status(404).end();
385
- return;
386
- }
387
- if (!resolveWithinRoot(root, relPath)) {
371
+ // No explicit dotfile check here: this mount never short-circuits,
372
+ // so `express.static`'s `dotfiles: "deny"` below is the authority.
373
+ if (resolveArtifactRequestPath(root, req.path, false) === null) {
388
374
  res.status(404).end();
389
375
  return;
390
376
  }
@@ -440,16 +426,7 @@ app.use(
440
426
  // eslint-disable-next-line sonarjs/regex-complexity -- flat extension allowlist with no nested quantifiers, ReDoS-safe; complexity is just the disjunction count
441
427
  const HTML_PREVIEW_EXT_RE = /\.(html?|png|jpe?g|webp|gif|svg|ico|mp4|webm|mov|m4v|ogv|mp3|ogg|oga|wav|m4a|aac)$/i;
442
428
  const HTML_DOCUMENT_EXT_RE = /\.html?$/i;
443
- let htmlsDirReal: string | null = null;
444
- async function getHtmlsDirReal(): Promise<string | null> {
445
- if (htmlsDirReal) return htmlsDirReal;
446
- try {
447
- htmlsDirReal = await fsRealpath(WORKSPACE_PATHS.htmls);
448
- return htmlsDirReal;
449
- } catch {
450
- return null;
451
- }
452
- }
429
+ const getHtmlsDirReal = makeCachedRealpath(WORKSPACE_PATHS.htmls);
453
430
 
454
431
  // Honour `X-Forwarded-*` so dev (Vite proxies `/artifacts/html` →
455
432
  // `localhost:3001` with `changeOrigin: true`) emits the browser-
@@ -488,26 +465,13 @@ app.use(
488
465
  res.status(404).end();
489
466
  return;
490
467
  }
491
- let relPath: string;
492
- try {
493
- relPath = decodeURIComponent(req.path.replace(/^\//, ""));
494
- } catch {
495
- res.status(404).end();
496
- return;
497
- }
498
- if (!resolveWithinRoot(root, relPath)) {
499
- res.status(404).end();
500
- return;
501
- }
502
- // Dotfile deny — `express.static` below enforces this for the
503
- // non-HTML branch via `dotfiles: "deny"`, but the HTML short-
504
- // circuit added in #1056 was bypassing the guard and would
505
- // happily serve `/artifacts/html/.hidden.html` (Codex review on
506
- // #1056). Apply the same policy uniformly so both branches
507
- // refuse any path component starting with `.`. The helper
508
- // splits on both `/` and `\` so an encoded backslash (`%5C`)
509
- // can't sneak a `dir\.hidden.html` past the check on Windows.
510
- if (containsDotfileSegment(relPath)) {
468
+ // denyDotfiles: the HTML short-circuit below serves the file itself
469
+ // (bypassing `express.static`'s `dotfiles: "deny"`), so this mount
470
+ // must reject dotfile segments in the guard — without it,
471
+ // `/artifacts/html/.hidden.html` would be served (Codex review on
472
+ // #1056).
473
+ const relPath = resolveArtifactRequestPath(root, req.path, true);
474
+ if (relPath === null) {
511
475
  res.status(404).end();
512
476
  return;
513
477
  }
@@ -560,16 +524,7 @@ const SVG_RESPONSE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src
560
524
  // `<img src>` request can't carry an Authorization header. Loopback-
561
525
  // only listener + `requireSameOrigin` remain the trust boundary.
562
526
  const SVG_EXT_RE = /\.svg$/i;
563
- let svgsDirReal: string | null = null;
564
- async function getSvgsDirReal(): Promise<string | null> {
565
- if (svgsDirReal) return svgsDirReal;
566
- try {
567
- svgsDirReal = await fsRealpath(WORKSPACE_PATHS.svgs);
568
- return svgsDirReal;
569
- } catch {
570
- return null;
571
- }
572
- }
527
+ const getSvgsDirReal = makeCachedRealpath(WORKSPACE_PATHS.svgs);
573
528
  app.use(
574
529
  "/artifacts/svg",
575
530
  async (req, res, next) => {
@@ -582,18 +537,10 @@ app.use(
582
537
  res.status(404).end();
583
538
  return;
584
539
  }
585
- let relPath: string;
586
- try {
587
- relPath = decodeURIComponent(req.path.replace(/^\//, ""));
588
- } catch {
589
- res.status(404).end();
590
- return;
591
- }
592
- if (!resolveWithinRoot(root, relPath)) {
593
- res.status(404).end();
594
- return;
595
- }
596
- if (containsDotfileSegment(relPath)) {
540
+ // denyDotfiles for the same reason as the html mount: this handler
541
+ // sets headers and serves via a bearer-bypassed <img> path, so the
542
+ // dotfile guard can't be left solely to `express.static`.
543
+ if (resolveArtifactRequestPath(root, req.path, true) === null) {
597
544
  res.status(404).end();
598
545
  return;
599
546
  }
@@ -1125,6 +1072,9 @@ function initEventPublishers(pubsub: IPubSub): void {
1125
1072
  // near the route mount; only the pub/sub instance is wired here.
1126
1073
  initAccountingEventPublisher(pubsub);
1127
1074
  initCollectionChangePublisher(pubsub);
1075
+ // MulmoScript generation events → plugin pubsub channel (the extracted
1076
+ // presentMulmoScript View's spinner/reload signal).
1077
+ initMulmoScriptGenerationPublisher(pubsub);
1128
1078
  }
1129
1079
 
1130
1080
  // System task defs + user-configurable schedule overrides. Split out
@@ -1166,6 +1116,7 @@ function buildSystemTaskDefs(): SystemTaskDef[] {
1166
1116
  // factory so the id/schedule/run can't drift across hosts. The override
1167
1117
  // loop below still mutates `task.schedule` host-side.
1168
1118
  feedRefreshTaskDef(),
1119
+ googleCalendarSyncTaskDef(),
1169
1120
  ];
1170
1121
 
1171
1122
  // Apply user-configurable schedule overrides from
@@ -1353,7 +1304,12 @@ process.on("SIGTERM", () => {
1353
1304
  // `http://<laptop-ip>:3001/api/*`), which combined with the
1354
1305
  // workspace file API is a credential-theft risk. Personal dev
1355
1306
  // tool — localhost is the right default.
1356
- const httpServer = app.listen(port, "127.0.0.1", async () => {
1307
+ // Express discards whatever the listen callback returns, so an async callback
1308
+ // would leave a throw in it as an unhandled rejection. The callback below
1309
+ // stays synchronous and hands the async setup off here. The server arrives as
1310
+ // a parameter rather than a closure variable — `httpServer` is this call's
1311
+ // own result and isn't defined yet at this point.
1312
+ const onListening = async (httpServer: ReturnType<typeof app.listen>): Promise<void> => {
1357
1313
  // Initialize the notifier engine synchronously, before any await
1358
1314
  // in this callback. The HTTP listener is already accepting
1359
1315
  // connections by the time this callback fires, so any awaited
@@ -1410,8 +1366,25 @@ process.on("SIGTERM", () => {
1410
1366
  httpServer.close(() => process.exit(1));
1411
1367
  setTimeout(() => process.exit(1), STARTUP_FAILURE_FORCE_EXIT_MS).unref();
1412
1368
  });
1369
+ };
1370
+
1371
+ const httpServer = app.listen(port, "127.0.0.1", () => {
1372
+ // Terminal handler, not a bare `void`: `onListening` runs the whole
1373
+ // post-listen setup, and a rejection outside its own
1374
+ // `startRuntimeServices(...).catch(...)` branch would otherwise be an
1375
+ // unhandled rejection on a half-initialised server.
1376
+ onListening(httpServer).catch((err: unknown) => {
1377
+ log.error("server", "post-listen initialization failed — exiting", { error: String(err) });
1378
+ process.exit(1);
1379
+ });
1413
1380
  });
1414
- })();
1381
+ })().catch((err: unknown) => {
1382
+ // Anything thrown before the listener is up (port resolution, token write)
1383
+ // lands here. Without this the failure surfaced only as an unhandled
1384
+ // rejection — no log line, and the exit code depended on the Node version.
1385
+ log.error("server", "server startup failed — exiting", { error: String(err) });
1386
+ process.exit(1);
1387
+ });
1415
1388
 
1416
1389
  function registerDebugTasks(taskManager: ITaskManager, pubsub: IPubSub) {
1417
1390
  let tick = 0;
@@ -123,12 +123,6 @@ export function collectPluginMetaDiagnostics(): readonly PluginMetaDiagnostic[]
123
123
  return cachedDiagnostics;
124
124
  }
125
125
 
126
- /** Reset the cache. Test-only — production code calls
127
- * `collectPluginMetaDiagnostics()` once at boot. */
128
- export function resetPluginMetaDiagnosticsCacheForTest(): void {
129
- cachedDiagnostics = null;
130
- }
131
-
132
126
  /** Run at server boot after the notifier engine is initialized.
133
127
  * Logs every diagnostic via `log.warn` and publishes one notification
134
128
  * per item so the bell shows them. Dedupes against the engine's
@@ -0,0 +1,58 @@
1
+ // MulmoClaude's binding of the shared mulmoScript server ops (phase 3 of
2
+ // plans/feat-mulmoscript-plugin.md). The mulmocast orchestration, realpath
3
+ // containment, dispatch routing, and generation tracking all live in
4
+ // @mulmoclaude/mulmoscript-plugin/server; this module supplies the
5
+ // host-specific backend — stories location, artifacts FileOps, hardened
6
+ // atomic writes, the ffmpeg probe, the logger, and the generation fan-out
7
+ // (session `pendingGenerations` channel + plugin pubsub) — and registers
8
+ // the built-in "mulmoScript" dispatch handler. Imported for side effect at
9
+ // boot (server/index.ts); the REST routes in api/routes/mulmo-script.ts
10
+ // consume the same instance.
11
+
12
+ import path from "path";
13
+ import { createMulmoScriptServerOps, createMulmoScriptDispatchHandler, GENERATION_EVENT } from "@mulmoclaude/mulmoscript-plugin/server";
14
+ import { WORKSPACE_PATHS } from "../workspace/paths.js";
15
+ import { writeFileAtomic } from "../utils/files/atomic.js";
16
+ import { depStatus } from "../system/optionalDeps.js";
17
+ import { log } from "../system/logger/index.js";
18
+ import { publishGeneration } from "../events/session-store/index.js";
19
+ import type { IPubSub } from "../events/pub-sub/index.js";
20
+ import { makeArtifactsFileOps, pluginChannelName } from "./runtime.js";
21
+ import { registerBuiltinDispatch } from "./builtin-dispatch.js";
22
+
23
+ /** Scope name — matches `wrapWithScope("mulmoScript", …)` in
24
+ * `src/plugins/presentMulmoScript/index.ts`, which is what the View's
25
+ * `useRuntime().dispatch` / `pubsub` use as the plugin namespace. */
26
+ const MULMOSCRIPT_SCOPE = "mulmoScript";
27
+
28
+ let pubsubInstance: IPubSub | null = null;
29
+
30
+ export const mulmoScriptOps = createMulmoScriptServerOps({
31
+ storiesDir: path.resolve(WORKSPACE_PATHS.stories),
32
+ artifacts: makeArtifactsFileOps(),
33
+ writeFileAtomic: async (absolutePath, data) => {
34
+ await writeFileAtomic(absolutePath, typeof data === "string" ? data : Buffer.from(data.buffer, data.byteOffset, data.byteLength));
35
+ },
36
+ isFfmpegAvailable: () => depStatus("ffmpeg")?.available,
37
+ // Edge-triggered by the package's tracker: fan each transition out to
38
+ // the per-session pendingGenerations channel (sidebar indicator;
39
+ // no-ops without a session) AND the plugin pubsub channel the
40
+ // extracted View subscribes to for spinners + reload-on-finish.
41
+ onGenerationEvent: (chatSessionId, event) => {
42
+ publishGeneration(chatSessionId, event.kind, event.filePath, event.key, event.done, event.error);
43
+ pubsubInstance?.publish(pluginChannelName(MULMOSCRIPT_SCOPE, GENERATION_EVENT), event);
44
+ },
45
+ log: {
46
+ info: (message, data) => log.info("mulmo-script", message, data),
47
+ warn: (message, data) => log.warn("mulmo-script", message, data),
48
+ error: (message, data) => log.error("mulmo-script", message, data),
49
+ },
50
+ });
51
+
52
+ registerBuiltinDispatch(MULMOSCRIPT_SCOPE, createMulmoScriptDispatchHandler(mulmoScriptOps));
53
+
54
+ /** Wired at boot (initEventPublishers) — publishes before this are
55
+ * session-only. */
56
+ export function initMulmoScriptGenerationPublisher(instance: IPubSub): void {
57
+ pubsubInstance = instance;
58
+ }
@@ -19,7 +19,6 @@ All data lives in the workspace directory as plain files:
19
19
  - `conversations/chat/` — chat session history (one .jsonl per session)
20
20
  - `conversations/memory/` — distilled user facts as topic files (`<type>/<topic>.md`); see the Memory section below for the index and read rules.
21
21
  - `conversations/summaries/` — journal output (daily / topics / archive)
22
- - `data/calendar/` — calendar events
23
22
  - `data/contacts/` — address book entries
24
23
  - `data/wiki/` — personal knowledge wiki (index.md, pages/, sources/, log.md)
25
24
  - `data/scheduler/` — scheduled tasks
@@ -42,11 +41,11 @@ Raw HTML tags work inside `.md` files too — use them when markdown's `![]()` c
42
41
 
43
42
  ## Attached file marker
44
43
 
45
- When a user message starts with one or more lines of the form
44
+ When a user message carries one or more lines of the form
46
45
 
47
46
  `[Attached file: <workspace-relative-path>]`
48
47
 
49
- the user has attached / pasted / dropped a file (or selected one in the UI) for this turn. **Each line is one file** — when the user attaches multiple files in the same turn, you will see multiple consecutive marker lines, in declaration order, before the user's actual message text. Every path always points at a real workspace file:
48
+ the user has attached / pasted / dropped a file (or selected one in the UI) for this turn. **Each line is one file** — when the user attaches multiple files in the same turn, you will see multiple consecutive marker lines, in declaration order. They usually sit before the user's actual message text, but on a slash-command turn they follow it (so the leading `/` stays at the very start for command resolution). Every path always points at a real workspace file:
50
49
 
51
50
  - `data/attachments/YYYY/MM/<id>.<ext>` — paste/drop/file-picker uploads. The extension reflects the actual format (`.png`, `.pdf`, `.docx`, `.xlsx`, `.txt`, etc.). PPTX uploads are converted server-side and the path you receive is the resulting `.pdf`; the original `.pptx` lives next to it under the same `<id>` if you ever need to inspect it.
52
51
  - `artifacts/images/YYYY/MM/<id>.png` — a generated / canvas / edited image the user selected from the sidebar.
@@ -7,13 +7,14 @@
7
7
  //
8
8
  // Factory (createGetCollection) keeps the mapping unit-testable with the engine
9
9
  // stubbed; the default export wires the real engine functions.
10
- import { listItems, loadCollection, toDetail } from "../../workspace/collections/index.js";
10
+ import { loadCollection, storeFor, toDetail, type CollectionItem, type LoadedCollection } from "../../workspace/collections/index.js";
11
11
  import type { CommandHandler, JsonObject } from "../commandChannel.js";
12
12
  import { clampLimit, clampOffset, deriveItems, pageResult } from "./collectionPage.js";
13
13
 
14
14
  export interface GetCollectionDeps {
15
15
  loadCollection: typeof loadCollection;
16
- listItems: typeof listItems;
16
+ /** Store-aware records loader (file records or a dataSource CSV's rows). */
17
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
17
18
  toDetail: typeof toDetail;
18
19
  }
19
20
 
@@ -25,8 +26,8 @@ export const createGetCollection =
25
26
  const limit = clampLimit(params.limit);
26
27
  const collection = await deps.loadCollection(slug);
27
28
  if (!collection) throw new Error(`collection '${slug}' not found`);
28
- const all = deriveItems(collection.schema, await deps.listItems(collection.dataDir));
29
+ const all = deriveItems(collection.schema, await deps.listRecords(collection));
29
30
  return pageResult(deps.toDetail(collection), all, offset, limit);
30
31
  };
31
32
 
32
- export const getCollection = createGetCollection({ loadCollection, listItems, toDetail });
33
+ export const getCollection = createGetCollection({ loadCollection, listRecords: (collection) => storeFor(collection).list(), toDetail });
@@ -2,19 +2,19 @@
2
2
  //
3
3
  // Returns one feed's detail + a PAGE of its records. A feed IS a
4
4
  // LoadedCollection with an `ingest` block, so this reuses the exact collection
5
- // page path (listItems + toDetail + collectionPage) and returns the SAME shape
5
+ // page path (storeFor + toDetail + collectionPage) and returns the SAME shape
6
6
  // as getCollection — the remote renders feed records with the same card view.
7
7
  // The feed is located via the feed registry (listFeeds), since feeds live under
8
8
  // their own registry rather than the collections dir.
9
9
  import { listFeeds as listFeedsRegistry } from "@mulmoclaude/core/feeds/server";
10
- import { listItems, toDetail } from "../../workspace/collections/index.js";
10
+ import { storeFor, toDetail, type LoadedCollection, type CollectionItem } from "../../workspace/collections/index.js";
11
11
  import { workspacePath } from "../../workspace/workspace.js";
12
12
  import type { CommandHandler, JsonObject } from "../commandChannel.js";
13
13
  import { clampLimit, clampOffset, deriveItems, pageResult } from "./collectionPage.js";
14
14
 
15
15
  export interface GetFeedDeps {
16
16
  listFeeds: typeof listFeedsRegistry;
17
- listItems: typeof listItems;
17
+ listRecords: (collection: LoadedCollection) => Promise<CollectionItem[]>;
18
18
  toDetail: typeof toDetail;
19
19
  workspaceRoot: string;
20
20
  }
@@ -28,8 +28,13 @@ export const createGetFeed =
28
28
  const feeds = await deps.listFeeds(deps.workspaceRoot);
29
29
  const feed = feeds.find((entry) => entry.slug === slug);
30
30
  if (!feed) throw new Error(`feed '${slug}' not found`);
31
- const all = deriveItems(feed.schema, await deps.listItems(feed.dataDir));
31
+ const all = deriveItems(feed.schema, await deps.listRecords(feed));
32
32
  return pageResult(deps.toDetail(feed), all, offset, limit);
33
33
  };
34
34
 
35
- export const getFeed = createGetFeed({ listFeeds: listFeedsRegistry, listItems, toDetail, workspaceRoot: workspacePath });
35
+ export const getFeed = createGetFeed({
36
+ listFeeds: listFeedsRegistry,
37
+ listRecords: (collection) => storeFor(collection).list(),
38
+ toDetail,
39
+ workspaceRoot: workspacePath,
40
+ });