mulmoclaude 1.2.0 → 1.3.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 (75) hide show
  1. package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
  2. package/client/assets/index-D3czxq1I.css +2 -0
  3. package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
  4. package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
  5. package/client/index.html +3 -3
  6. package/package.json +6 -5
  7. package/server/agent/attachmentConverter.ts +0 -25
  8. package/server/api/auth/viewToken.ts +10 -8
  9. package/server/api/routes/agent.ts +35 -10
  10. package/server/api/routes/collections.ts +150 -96
  11. package/server/api/routes/files.ts +57 -54
  12. package/server/api/routes/mulmo-script.ts +257 -895
  13. package/server/api/routes/wiki/history.ts +17 -19
  14. package/server/events/collection-change.ts +0 -5
  15. package/server/events/file-change.ts +1 -6
  16. package/server/index.ts +25 -76
  17. package/server/plugins/diagnostics.ts +0 -6
  18. package/server/plugins/mulmoscript-server.ts +58 -0
  19. package/server/prompts/system/system.md +2 -2
  20. package/server/remoteHost/handlers/getCollection.ts +5 -4
  21. package/server/remoteHost/handlers/googleCalendar.ts +50 -3
  22. package/server/remoteHost/handlers/index.ts +3 -1
  23. package/server/utils/files/html-store.ts +2 -16
  24. package/server/utils/files/journal-io.ts +0 -12
  25. package/server/utils/files/plugins-io.ts +2 -11
  26. package/server/utils/files/safe.ts +39 -0
  27. package/server/utils/httpError.ts +6 -0
  28. package/server/utils/time.ts +0 -3
  29. package/server/workspace/collections/index.ts +1 -0
  30. package/server/workspace/collections/remoteView.ts +13 -4
  31. package/server/workspace/journal/state.ts +1 -9
  32. package/server/workspace/memory/topic-index-hook.ts +0 -9
  33. package/server/workspace/memory/topic-io.ts +0 -4
  34. package/server/workspace/skills/external/catalog.ts +24 -13
  35. package/server/workspace/skills/writer.ts +20 -16
  36. package/server/workspace/wiki-pages/snapshot.ts +13 -12
  37. package/src/composables/useFileDropZone.ts +3 -15
  38. package/src/config/apiRoutes.ts +6 -0
  39. package/src/config/roles.ts +0 -4
  40. package/src/lang/de.ts +0 -30
  41. package/src/lang/en.ts +0 -30
  42. package/src/lang/es.ts +0 -30
  43. package/src/lang/fr.ts +0 -30
  44. package/src/lang/ja.ts +0 -30
  45. package/src/lang/ko.ts +0 -30
  46. package/src/lang/pt-BR.ts +0 -30
  47. package/src/lang/zh.ts +0 -30
  48. package/src/plugins/accounting/index.ts +0 -2
  49. package/src/plugins/api.ts +0 -6
  50. package/src/plugins/canvas/index.ts +0 -2
  51. package/src/plugins/chart/index.ts +0 -2
  52. package/src/plugins/editImages/index.ts +0 -2
  53. package/src/plugins/generateImage/index.ts +0 -2
  54. package/src/plugins/manageSkills/index.ts +0 -2
  55. package/src/plugins/markdown/index.ts +0 -2
  56. package/src/plugins/photoLocations/index.ts +0 -2
  57. package/src/plugins/presentCollection/index.ts +0 -2
  58. package/src/plugins/presentForm/index.ts +0 -2
  59. package/src/plugins/presentHtml/index.ts +0 -2
  60. package/src/plugins/presentMulmoScript/definition.ts +11 -116
  61. package/src/plugins/presentMulmoScript/index.ts +62 -12
  62. package/src/plugins/presentSVG/index.ts +0 -2
  63. package/src/plugins/skill/index.ts +0 -3
  64. package/src/plugins/spreadsheet/engine/parser.ts +0 -103
  65. package/src/plugins/spreadsheet/index.ts +0 -2
  66. package/src/plugins/textResponse/index.ts +0 -3
  67. package/src/plugins/wiki/index.ts +0 -2
  68. package/src/types/notification.ts +0 -10
  69. package/src/utils/api.ts +0 -4
  70. package/client/assets/index-D5nJbMO1.css +0 -2
  71. package/server/api/routes/mulmoScriptValidate.ts +0 -101
  72. package/server/utils/mulmoErrorCapture.ts +0 -93
  73. package/src/plugins/presentMulmoScript/Preview.vue +0 -23
  74. package/src/plugins/presentMulmoScript/View.vue +0 -1949
  75. 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
@@ -63,8 +63,3 @@ export function initCollectionChangePublisher(instance: IPubSub): void {
63
63
  }
64
64
  });
65
65
  }
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
- }
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,8 +97,7 @@ 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";
@@ -351,17 +354,7 @@ app.use("/api", (req, res, next) => {
351
354
  // 3. `dotfiles: deny` + `fallthrough: false` on `express.static`
352
355
  // itself, plus its built-in `..` normalize for path traversal.
353
356
  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
- }
357
+ const getImagesDirReal = makeCachedRealpath(WORKSPACE_PATHS.images);
365
358
  app.use(
366
359
  "/artifacts/images",
367
360
  async (req, res, next) => {
@@ -374,17 +367,9 @@ app.use(
374
367
  res.status(404).end();
375
368
  return;
376
369
  }
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)) {
370
+ // No explicit dotfile check here: this mount never short-circuits,
371
+ // so `express.static`'s `dotfiles: "deny"` below is the authority.
372
+ if (resolveArtifactRequestPath(root, req.path, false) === null) {
388
373
  res.status(404).end();
389
374
  return;
390
375
  }
@@ -440,16 +425,7 @@ app.use(
440
425
  // eslint-disable-next-line sonarjs/regex-complexity -- flat extension allowlist with no nested quantifiers, ReDoS-safe; complexity is just the disjunction count
441
426
  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
427
  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
- }
428
+ const getHtmlsDirReal = makeCachedRealpath(WORKSPACE_PATHS.htmls);
453
429
 
454
430
  // Honour `X-Forwarded-*` so dev (Vite proxies `/artifacts/html` →
455
431
  // `localhost:3001` with `changeOrigin: true`) emits the browser-
@@ -488,26 +464,13 @@ app.use(
488
464
  res.status(404).end();
489
465
  return;
490
466
  }
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)) {
467
+ // denyDotfiles: the HTML short-circuit below serves the file itself
468
+ // (bypassing `express.static`'s `dotfiles: "deny"`), so this mount
469
+ // must reject dotfile segments in the guard — without it,
470
+ // `/artifacts/html/.hidden.html` would be served (Codex review on
471
+ // #1056).
472
+ const relPath = resolveArtifactRequestPath(root, req.path, true);
473
+ if (relPath === null) {
511
474
  res.status(404).end();
512
475
  return;
513
476
  }
@@ -560,16 +523,7 @@ const SVG_RESPONSE_CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src
560
523
  // `<img src>` request can't carry an Authorization header. Loopback-
561
524
  // only listener + `requireSameOrigin` remain the trust boundary.
562
525
  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
- }
526
+ const getSvgsDirReal = makeCachedRealpath(WORKSPACE_PATHS.svgs);
573
527
  app.use(
574
528
  "/artifacts/svg",
575
529
  async (req, res, next) => {
@@ -582,18 +536,10 @@ app.use(
582
536
  res.status(404).end();
583
537
  return;
584
538
  }
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)) {
539
+ // denyDotfiles for the same reason as the html mount: this handler
540
+ // sets headers and serves via a bearer-bypassed <img> path, so the
541
+ // dotfile guard can't be left solely to `express.static`.
542
+ if (resolveArtifactRequestPath(root, req.path, true) === null) {
597
543
  res.status(404).end();
598
544
  return;
599
545
  }
@@ -1125,6 +1071,9 @@ function initEventPublishers(pubsub: IPubSub): void {
1125
1071
  // near the route mount; only the pub/sub instance is wired here.
1126
1072
  initAccountingEventPublisher(pubsub);
1127
1073
  initCollectionChangePublisher(pubsub);
1074
+ // MulmoScript generation events → plugin pubsub channel (the extracted
1075
+ // presentMulmoScript View's spinner/reload signal).
1076
+ initMulmoScriptGenerationPublisher(pubsub);
1128
1077
  }
1129
1078
 
1130
1079
  // System task defs + user-configurable schedule overrides. Split out
@@ -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
+ }
@@ -42,11 +42,11 @@ Raw HTML tags work inside `.md` files too — use them when markdown's `![]()` c
42
42
 
43
43
  ## Attached file marker
44
44
 
45
- When a user message starts with one or more lines of the form
45
+ When a user message carries one or more lines of the form
46
46
 
47
47
  `[Attached file: <workspace-relative-path>]`
48
48
 
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:
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. 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
50
 
51
51
  - `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
52
  - `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 });
@@ -8,17 +8,22 @@
8
8
  import {
9
9
  createCalendarEvent,
10
10
  DEFAULT_LIST_MAX_RESULTS,
11
+ getCalendarColors,
11
12
  getGoogleAccessToken,
12
13
  isIsoDateTimeWithOffset,
13
14
  listCalendarEvents,
15
+ listCalendars,
14
16
  MAX_LIST_RESULTS,
17
+ type CalendarColorEntry,
15
18
  } from "@mulmoclaude/core/google";
16
- import type { CommandHandler, JsonObject } from "../commandChannel.js";
19
+ import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
17
20
 
18
21
  export interface GoogleCalendarDeps {
19
22
  getAccessToken: typeof getGoogleAccessToken;
20
23
  createEvent: typeof createCalendarEvent;
21
24
  listEvents: typeof listCalendarEvents;
25
+ listCalendars: typeof listCalendars;
26
+ getColors: typeof getCalendarColors;
22
27
  }
23
28
 
24
29
  const requiredString = (params: JsonObject, key: string): string => {
@@ -27,6 +32,15 @@ const requiredString = (params: JsonObject, key: string): string => {
27
32
  return value;
28
33
  };
29
34
 
35
+ const optionalString = (params: JsonObject, key: string): string | undefined => {
36
+ const value = params[key];
37
+ if (value === undefined || value === null) return undefined;
38
+ if (typeof value !== "string" || value.trim() === "") throw new Error(`${key} must be a non-empty string`);
39
+ // Return trimmed so whitespace can't reach the Google API (matches the
40
+ // plugin's Zod .trim() normalization).
41
+ return value.trim();
42
+ };
43
+
30
44
  // Calendar's `dateTime`/`timeMin` are RFC3339 and reject date-only,
31
45
  // offset-less, or impossible values with an opaque 400, so the strict shared
32
46
  // validator runs here where the remote gets an actionable message.
@@ -47,6 +61,14 @@ const clampMaxResults = (value: unknown): number => {
47
61
  return Math.min(Math.max(value, 1), MAX_LIST_RESULTS);
48
62
  };
49
63
 
64
+ // Spread rebuilds an anonymous object type — the named CalendarColorEntry
65
+ // interface (no index signature) can't satisfy the channel's structural
66
+ // JsonValue directly (same constraint as CalendarEventSummary).
67
+ const toColorMapJson = (map: Record<string, CalendarColorEntry>): JsonObject =>
68
+ Object.fromEntries(
69
+ Object.entries(map).map(([colorId, entry]): [string, JsonValue] => [colorId, { background: entry.background, foreground: entry.foreground }]),
70
+ );
71
+
50
72
  export const createGoogleCalendarCreateEvent =
51
73
  (deps: GoogleCalendarDeps): CommandHandler =>
52
74
  async (params: JsonObject) => {
@@ -55,6 +77,8 @@ export const createGoogleCalendarCreateEvent =
55
77
  startDateTime: asDateTime(requiredString(params, "start"), "start"),
56
78
  endDateTime: asDateTime(requiredString(params, "end"), "end"),
57
79
  description: typeof params.description === "string" ? params.description : undefined,
80
+ calendarId: optionalString(params, "calendarId"),
81
+ colorId: optionalString(params, "colorId"),
58
82
  };
59
83
  const event = await deps.createEvent(await deps.getAccessToken(), input);
60
84
  // Spread rebuilds an anonymous object type — the CalendarEventSummary
@@ -68,10 +92,33 @@ export const createGoogleCalendarListEvents =
68
92
  async (params: JsonObject) => {
69
93
  const timeMin = optionalDateTime(params, "timeMin");
70
94
  const maxResults = clampMaxResults(params.maxResults);
71
- const events = await deps.listEvents(await deps.getAccessToken(), { timeMin, maxResults });
95
+ const calendarId = optionalString(params, "calendarId");
96
+ const events = await deps.listEvents(await deps.getAccessToken(), { timeMin, maxResults, calendarId });
72
97
  return { events: events.map((event) => ({ ...event })) };
73
98
  };
74
99
 
75
- const deps: GoogleCalendarDeps = { getAccessToken: getGoogleAccessToken, createEvent: createCalendarEvent, listEvents: listCalendarEvents };
100
+ export const createGoogleCalendarListCalendars =
101
+ (deps: GoogleCalendarDeps): CommandHandler =>
102
+ async () => {
103
+ const calendars = await deps.listCalendars(await deps.getAccessToken());
104
+ return { calendars: calendars.map((calendar) => ({ ...calendar })) };
105
+ };
106
+
107
+ export const createGoogleCalendarColors =
108
+ (deps: GoogleCalendarDeps): CommandHandler =>
109
+ async () => {
110
+ const colors = await deps.getColors(await deps.getAccessToken());
111
+ return { colors: { event: toColorMapJson(colors.event), calendar: toColorMapJson(colors.calendar) } };
112
+ };
113
+
114
+ const deps: GoogleCalendarDeps = {
115
+ getAccessToken: getGoogleAccessToken,
116
+ createEvent: createCalendarEvent,
117
+ listEvents: listCalendarEvents,
118
+ listCalendars,
119
+ getColors: getCalendarColors,
120
+ };
76
121
  export const googleCalendarCreateEvent = createGoogleCalendarCreateEvent(deps);
77
122
  export const googleCalendarListEvents = createGoogleCalendarListEvents(deps);
123
+ export const googleCalendarListCalendars = createGoogleCalendarListCalendars(deps);
124
+ export const googleCalendarColors = createGoogleCalendarColors(deps);
@@ -6,7 +6,7 @@ import { getCollection } from "./getCollection.js";
6
6
  import { getFeed } from "./getFeed.js";
7
7
  import { getRemoteView } from "./getRemoteView.js";
8
8
  import { getRemoteViewItems } from "./getRemoteViewItems.js";
9
- import { googleCalendarCreateEvent, googleCalendarListEvents } from "./googleCalendar.js";
9
+ import { googleCalendarColors, googleCalendarCreateEvent, googleCalendarListCalendars, googleCalendarListEvents } from "./googleCalendar.js";
10
10
  import { listAccountingBooks } from "./listAccountingBooks.js";
11
11
  import { listCollections } from "./listCollections.js";
12
12
  import { listFeeds } from "./listFeeds.js";
@@ -29,4 +29,6 @@ export const handlers: CommandHandlers = {
29
29
  startChat,
30
30
  "google.calendar.createEvent": googleCalendarCreateEvent,
31
31
  "google.calendar.listEvents": googleCalendarListEvents,
32
+ "google.calendar.listCalendars": googleCalendarListCalendars,
33
+ "google.calendar.colors": googleCalendarColors,
32
34
  };
@@ -1,20 +1,6 @@
1
- import path from "path";
2
- import { workspacePath } from "../../workspace/workspace.js";
3
1
  import { WORKSPACE_DIRS } from "../../workspace/paths.js";
4
- import { writeFileAtomic } from "./atomic.js";
5
2
  import { makePathValidator } from "./path-validator.js";
6
3
 
7
- // Strict overwriteHtml's path.join doesn't normalize traversal, so this gate is the primary defence.
4
+ // The sole guard on html-store paths: `path.join` elsewhere doesn't normalize
5
+ // traversal, so callers must pre-check writes with this validator.
8
6
  export const isHtmlPath = makePathValidator({ prefix: WORKSPACE_DIRS.htmls, ext: ".html" });
9
-
10
- // Defense in depth (matches `overwriteSvg`): if a caller forgets to
11
- // pre-check via `isHtmlPath`, `path.join(workspacePath, relativePath)`
12
- // would silently produce a traversal escape. The re-check inside the
13
- // write closes that trust chain.
14
- export async function overwriteHtml(relativePath: string, content: string): Promise<void> {
15
- if (!isHtmlPath(relativePath)) {
16
- throw new Error(`invalid html path: ${relativePath}`);
17
- }
18
- const absPath = path.join(workspacePath, relativePath);
19
- await writeFileAtomic(absPath, content);
20
- }
@@ -7,20 +7,8 @@ import { isEnoent } from "./safe.js";
7
7
  import { log } from "../../system/logger/index.js";
8
8
  import { summariesRoot, dailyPathFor, topicPathFor, TOPICS_DIR, INDEX_FILE, STATE_FILE, DAILY_DIR, ARCHIVE_DIR } from "../../workspace/journal/paths.js";
9
9
 
10
- import { statSync } from "node:fs";
11
-
12
10
  const root = (rootOverride?: string) => rootOverride ?? workspacePath;
13
11
 
14
- export function journalStateExists(rootOverride?: string): boolean {
15
- const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
16
- try {
17
- statSync(filePath);
18
- return true;
19
- } catch {
20
- return false;
21
- }
22
- }
23
-
24
12
  export async function readJournalState<T>(fallback: T, rootOverride?: string): Promise<T> {
25
13
  const filePath = path.join(summariesRoot(root(rootOverride)), STATE_FILE);
26
14
  try {
@@ -15,10 +15,9 @@
15
15
  // written ledger never bricks server boot. Writes go through the
16
16
  // atomic helper, so a crashed install can't leave a corrupt file.
17
17
 
18
- import { existsSync } from "node:fs";
19
- import { mkdir, readFile } from "node:fs/promises";
18
+ import { readFile } from "node:fs/promises";
20
19
  import path from "node:path";
21
- import { loadJsonFile, writeJsonAtomic } from "./json.js";
20
+ import { loadJsonFile } from "./json.js";
22
21
  import { WORKSPACE_PATHS } from "../../workspace/paths.js";
23
22
 
24
23
  export interface LedgerEntry {
@@ -49,14 +48,6 @@ export function readLedger(): LedgerEntry[] {
49
48
  return sanitiseLedger(raw);
50
49
  }
51
50
 
52
- export async function writeLedger(entries: readonly LedgerEntry[]): Promise<void> {
53
- const dir = path.dirname(WORKSPACE_PATHS.pluginsLedger);
54
- if (!existsSync(dir)) {
55
- await mkdir(dir, { recursive: true });
56
- }
57
- await writeJsonAtomic(WORKSPACE_PATHS.pluginsLedger, [...entries]);
58
- }
59
-
60
51
  /** Read a runtime-plugin asset (extracted under
61
52
  * `plugins/.cache/<name>/<version>/`) and return its bytes plus
62
53
  * the inferred Content-Type. The route handler in
@@ -99,6 +99,45 @@ export function hasTraversalSegment(value: string): boolean {
99
99
  return value.split(/[/\\]/).some((segment) => segment === ".." || segment === ".");
100
100
  }
101
101
 
102
+ // Lazily realpath a directory once and cache the result. Returns null
103
+ // until the directory exists on disk (a fresh workspace hasn't created
104
+ // it yet) and retries on the next call. The /artifacts/* static mounts
105
+ // each need their storage root as a realpath for the traversal check,
106
+ // but the root may not be materialised at boot.
107
+ export function makeCachedRealpath(dir: string): () => Promise<string | null> {
108
+ let cached: string | null = null;
109
+ return async () => {
110
+ if (cached) return cached;
111
+ try {
112
+ cached = await promises.realpath(dir);
113
+ return cached;
114
+ } catch {
115
+ return null;
116
+ }
117
+ };
118
+ }
119
+
120
+ // Decode and traversal-guard an `/artifacts/*` request path against an
121
+ // already-realpath'd storage root. Returns the in-root relative path to
122
+ // serve, or null when the URL is malformed, escapes the root, or (when
123
+ // `denyDotfiles`) touches a dotfile segment. The images / html / svg
124
+ // static mounts share this so the decode + traversal + dotfile policy is
125
+ // defined once. `rootReal` MUST be a realpath (see `resolveWithinRoot`).
126
+ export function resolveArtifactRequestPath(rootReal: string, reqPath: string, denyDotfiles: boolean): string | null {
127
+ let relPath: string;
128
+ try {
129
+ // decodeURIComponent throws URIError on malformed escapes (`%ZZ`,
130
+ // stray `%`). Fail closed so a junk URL 404s instead of bubbling a
131
+ // 500 out of the express error chain.
132
+ relPath = decodeURIComponent(reqPath.replace(/^\//, ""));
133
+ } catch {
134
+ return null;
135
+ }
136
+ if (!resolveWithinRoot(rootReal, relPath)) return null;
137
+ if (denyDotfiles && containsDotfileSegment(relPath)) return null;
138
+ return relPath;
139
+ }
140
+
102
141
  // `rootReal` MUST already be a realpath. Returns null on traversal or if either path doesn't exist on disk.
103
142
  export function resolveWithinRoot(rootReal: string, relPath: string): string | null {
104
143
  const normalized = path.normalize(relPath || "");
@@ -50,6 +50,12 @@ export function notFound(res: Response, error: string): Response {
50
50
  return sendError(res, 404, error);
51
51
  }
52
52
 
53
+ /** 405 Method Not Allowed — the resource exists but refuses this verb
54
+ * (e.g. a write against a read-only `dataSource` collection). */
55
+ export function methodNotAllowed(res: Response, error: string): Response {
56
+ return sendError(res, 405, error);
57
+ }
58
+
53
59
  /** 409 Conflict — duplicate, concurrent modification, already running, etc. */
54
60
  export function conflict(res: Response, error: string): Response {
55
61
  return sendError(res, 409, error);
@@ -47,6 +47,3 @@ export const SUBPROCESS_WORK_TIMEOUT_MS = ONE_MINUTE_MS;
47
47
 
48
48
  /** CLI subprocess timeout (claude -p for summarization, etc.) */
49
49
  export const CLI_SUBPROCESS_TIMEOUT_MS = 5 * ONE_MINUTE_MS;
50
-
51
- /** Maximum one-shot notification delay */
52
- export const MAX_NOTIFICATION_DELAY_SEC = 3_600; // 1 hour in seconds
@@ -11,6 +11,7 @@ export { validateCollectionRecords, validateRecordObject, COMPUTED_TYPES, type R
11
11
  export { buildWorkspaceOntology, schemaRelations, type CollectionOntologyEntry, type OntologyRelation } from "@mulmoclaude/core/collection/server";
12
12
  export { applyMutateAction, firstMutateParamProblem, type MutateActionOutcome } from "@mulmoclaude/core/collection/server";
13
13
  export { enrichItems, computeCollectionIcon } from "@mulmoclaude/core/collection/server";
14
+ export { storeFor, collectionWritable, readOnlyRefusal, type CollectionStore } from "@mulmoclaude/core/collection/server";
14
15
  export { deleteCollection, deleteCollectionRefusalMessage, type DeleteCollectionResult } from "@mulmoclaude/core/collection/server";
15
16
  export { deleteCustomView, type DeleteViewResult } from "@mulmoclaude/core/collection/server";
16
17
  export {