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.
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-B53YSjaC.js} +1 -1
- package/client/assets/index-D3czxq1I.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-DUdqhWg6.js} +42 -43
- package/client/assets/{marp-Bs_qwoKZ.js → marp-CyWxL3bc.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +6 -5
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/api/auth/viewToken.ts +10 -8
- package/server/api/routes/agent.ts +35 -10
- package/server/api/routes/collections.ts +150 -96
- package/server/api/routes/files.ts +57 -54
- package/server/api/routes/mulmo-script.ts +257 -895
- package/server/api/routes/wiki/history.ts +17 -19
- package/server/events/collection-change.ts +0 -5
- package/server/events/file-change.ts +1 -6
- package/server/index.ts +25 -76
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -2
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/utils/files/html-store.ts +2 -16
- package/server/utils/files/journal-io.ts +0 -12
- package/server/utils/files/plugins-io.ts +2 -11
- package/server/utils/files/safe.ts +39 -0
- package/server/utils/httpError.ts +6 -0
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +1 -0
- package/server/workspace/collections/remoteView.ts +13 -4
- package/server/workspace/journal/state.ts +1 -9
- package/server/workspace/memory/topic-index-hook.ts +0 -9
- package/server/workspace/memory/topic-io.ts +0 -4
- package/server/workspace/skills/external/catalog.ts +24 -13
- package/server/workspace/skills/writer.ts +20 -16
- package/server/workspace/wiki-pages/snapshot.ts +13 -12
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/config/apiRoutes.ts +6 -0
- package/src/config/roles.ts +0 -4
- package/src/lang/de.ts +0 -30
- package/src/lang/en.ts +0 -30
- package/src/lang/es.ts +0 -30
- package/src/lang/fr.ts +0 -30
- package/src/lang/ja.ts +0 -30
- package/src/lang/ko.ts +0 -30
- package/src/lang/pt-BR.ts +0 -30
- package/src/lang/zh.ts +0 -30
- package/src/plugins/accounting/index.ts +0 -2
- package/src/plugins/api.ts +0 -6
- package/src/plugins/canvas/index.ts +0 -2
- package/src/plugins/chart/index.ts +0 -2
- package/src/plugins/editImages/index.ts +0 -2
- package/src/plugins/generateImage/index.ts +0 -2
- package/src/plugins/manageSkills/index.ts +0 -2
- package/src/plugins/markdown/index.ts +0 -2
- package/src/plugins/photoLocations/index.ts +0 -2
- package/src/plugins/presentCollection/index.ts +0 -2
- package/src/plugins/presentForm/index.ts +0 -2
- package/src/plugins/presentHtml/index.ts +0 -2
- package/src/plugins/presentMulmoScript/definition.ts +11 -116
- package/src/plugins/presentMulmoScript/index.ts +62 -12
- package/src/plugins/presentSVG/index.ts +0 -2
- package/src/plugins/skill/index.ts +0 -3
- package/src/plugins/spreadsheet/engine/parser.ts +0 -103
- package/src/plugins/spreadsheet/index.ts +0 -2
- package/src/plugins/textResponse/index.ts +0 -3
- package/src/plugins/wiki/index.ts +0 -2
- package/src/types/notification.ts +0 -10
- package/src/utils/api.ts +0 -4
- package/client/assets/index-D5nJbMO1.css +0 -2
- package/server/api/routes/mulmoScriptValidate.ts +0 -101
- package/server/utils/mulmoErrorCapture.ts +0 -93
- package/src/plugins/presentMulmoScript/Preview.vue +0 -23
- package/src/plugins/presentMulmoScript/View.vue +0 -1949
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
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);
|
package/server/utils/time.ts
CHANGED
|
@@ -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 {
|