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.
- package/README.md +1 -1
- package/client/assets/{PluginScopedRoot-CjiZ2FJI.js → PluginScopedRoot-DYk4nRpW.js} +1 -1
- package/client/assets/index-7QnbsZZr.css +2 -0
- package/client/assets/{index-yQkLdtMk.js → index-C3SRVT2p.js} +84 -85
- package/client/assets/{marp-Bs_qwoKZ.js → marp-DxctLEy1.js} +12 -3
- package/client/index.html +3 -3
- package/package.json +7 -6
- package/server/agent/attachmentConverter.ts +0 -25
- package/server/agent/mcp-server.ts +27 -8
- package/server/api/auth/viewToken.ts +16 -8
- package/server/api/routes/agent.ts +49 -11
- package/server/api/routes/collections.ts +274 -102
- 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/build/dispatcher.mjs +2 -2
- package/server/events/collection-change.ts +1 -5
- package/server/events/file-change.ts +1 -6
- package/server/events/pub-sub/index.ts +15 -2
- package/server/events/relay-client.ts +6 -1
- package/server/events/session-store/index.ts +11 -2
- package/server/index.ts +51 -78
- package/server/plugins/diagnostics.ts +0 -6
- package/server/plugins/mulmoscript-server.ts +58 -0
- package/server/prompts/system/system.md +2 -3
- package/server/remoteHost/handlers/getCollection.ts +5 -4
- package/server/remoteHost/handlers/getFeed.ts +10 -5
- package/server/remoteHost/handlers/googleCalendar.ts +50 -3
- package/server/remoteHost/handlers/index.ts +3 -1
- package/server/services/translation/index.ts +10 -3
- package/server/utils/errors.ts +7 -21
- 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/text.ts +7 -23
- package/server/utils/time.ts +0 -3
- package/server/workspace/collections/index.ts +6 -4
- package/server/workspace/collections/remoteView.ts +31 -22
- package/server/workspace/collections/watcher.ts +1 -0
- package/server/workspace/custom-dirs.ts +16 -6
- 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/reference-dirs.ts +8 -3
- 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/components/SettingsGoogleTab.vue +4 -1
- package/src/components/StackView.vue +12 -1
- package/src/composables/collections/uiHost.ts +2 -1
- package/src/composables/useChatScroll.ts +18 -6
- package/src/composables/useFileDropZone.ts +3 -15
- package/src/composables/useFileSelection.ts +4 -1
- package/src/composables/useStickToBottom.ts +51 -0
- package/src/config/apiRoutes.ts +20 -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/src/utils/dom/scrollable.ts +15 -0
- package/src/utils/errors.ts +7 -38
- 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
|
|
@@ -126,7 +126,7 @@ async function handleConfigRefresh(payload) {
|
|
|
126
126
|
await safePost(req);
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// packages/core/dist/
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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 });
|
|
@@ -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 (
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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({
|
|
35
|
+
export const getFeed = createGetFeed({
|
|
36
|
+
listFeeds: listFeedsRegistry,
|
|
37
|
+
listRecords: (collection) => storeFor(collection).list(),
|
|
38
|
+
toDetail,
|
|
39
|
+
workspaceRoot: workspacePath,
|
|
40
|
+
});
|