mulmoclaude 0.3.0 → 0.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/bin/mulmoclaude.js +7 -24
- package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
- package/client/assets/index-CubzmCVK.css +2 -0
- package/client/assets/{index-eHWB79u5.js → index-DtcyExH9.js} +80 -61
- package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
- package/client/index.html +2 -4
- package/package.json +13 -13
- package/server/agent/attachmentConverter.ts +2 -2
- package/server/agent/index.ts +9 -3
- package/server/agent/mcp-tools/index.ts +6 -6
- package/server/agent/mcp-tools/x.ts +2 -1
- package/server/agent/prompt.ts +187 -26
- package/server/agent/resumeFailover.ts +5 -5
- package/server/agent/sandboxMounts.ts +3 -3
- package/server/api/auth/bearerAuth.ts +3 -3
- package/server/api/auth/token.ts +2 -2
- package/server/api/routes/agent.ts +21 -3
- package/server/api/routes/config.ts +1 -1
- package/server/api/routes/files.ts +13 -12
- package/server/api/routes/html.ts +2 -2
- package/server/api/routes/image.ts +7 -7
- package/server/api/routes/mulmo-script.ts +33 -31
- package/server/api/routes/pdf.ts +2 -2
- package/server/api/routes/plugins.ts +16 -6
- package/server/api/routes/roles.ts +2 -2
- package/server/api/routes/scheduler.ts +8 -6
- package/server/api/routes/schedulerTasks.ts +5 -3
- package/server/api/routes/sessions.ts +2 -2
- package/server/api/routes/sessionsCursor.ts +4 -4
- package/server/api/routes/skills.ts +5 -5
- package/server/api/routes/sources.ts +3 -3
- package/server/api/routes/todosHandlers.ts +1 -1
- package/server/api/routes/todosItemsHandlers.ts +14 -14
- package/server/api/routes/wiki.ts +22 -8
- package/server/api/sandboxStatus.ts +1 -1
- package/server/events/notifications.ts +6 -6
- package/server/events/pub-sub/index.ts +3 -3
- package/server/events/relay-client.ts +17 -16
- package/server/index.ts +40 -46
- package/server/system/config.ts +5 -5
- package/server/system/credentials.ts +7 -5
- package/server/system/env.ts +5 -5
- package/server/utils/files/atomic.ts +11 -11
- package/server/utils/files/image-store.ts +17 -6
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/json.ts +5 -5
- package/server/utils/files/markdown-store.ts +4 -4
- package/server/utils/files/reference-dirs-io.ts +3 -3
- package/server/utils/files/roles-io.ts +4 -4
- package/server/utils/files/safe.ts +14 -14
- package/server/utils/files/scheduler-overrides-io.ts +2 -2
- package/server/utils/files/spreadsheet-store.ts +5 -5
- package/server/utils/files/workspace-io.ts +12 -12
- package/server/utils/gemini.ts +2 -2
- package/server/utils/gitignore.ts +9 -9
- package/server/utils/json.ts +5 -5
- package/server/utils/logBackgroundError.ts +12 -3
- package/server/utils/markdown.ts +5 -5
- package/server/utils/port.d.mts +6 -0
- package/server/utils/port.mjs +48 -0
- package/server/utils/request.ts +12 -6
- package/server/utils/spawn.ts +1 -1
- package/server/utils/types.ts +2 -2
- package/server/workspace/chat-index/summarizer.ts +4 -4
- package/server/workspace/custom-dirs.ts +5 -5
- package/server/workspace/journal/diff.ts +2 -2
- package/server/workspace/journal/index.ts +4 -4
- package/server/workspace/journal/optimizationPass.ts +2 -2
- package/server/workspace/journal/state.ts +6 -6
- package/server/workspace/paths.ts +3 -3
- package/server/workspace/reference-dirs.ts +3 -3
- package/server/workspace/skills/parser.ts +6 -6
- package/server/workspace/skills/scheduler.ts +3 -3
- package/server/workspace/skills/writer.ts +3 -3
- package/server/workspace/sources/arxivDiscovery.ts +2 -2
- package/server/workspace/sources/fetchers/rss.ts +5 -5
- package/server/workspace/sources/fetchers/rssParser.ts +4 -4
- package/server/workspace/sources/interests.ts +3 -3
- package/server/workspace/sources/paths.ts +6 -6
- package/server/workspace/sources/pipeline/fetch.ts +36 -13
- package/server/workspace/sources/pipeline/index.ts +2 -7
- package/server/workspace/sources/pipeline/notify.ts +3 -3
- package/server/workspace/sources/pipeline/plan.ts +11 -9
- package/server/workspace/sources/pipeline/write.ts +5 -5
- package/server/workspace/sources/rateLimiter.ts +1 -1
- package/server/workspace/sources/sourceState.ts +9 -4
- package/server/workspace/sources/types.ts +9 -0
- package/server/workspace/sources/urls.ts +1 -1
- package/server/workspace/tool-trace/classify.ts +4 -4
- package/server/workspace/workspace.ts +7 -7
- package/src/App.vue +286 -112
- package/src/components/CanvasViewToggle.vue +10 -7
- package/src/components/ChatInput.vue +60 -26
- package/src/components/FileContentHeader.vue +7 -4
- package/src/components/FileContentRenderer.vue +20 -6
- package/src/components/FileTree.vue +6 -3
- package/src/components/FileTreePane.vue +11 -8
- package/src/components/FilesView.vue +5 -3
- package/src/components/LockStatusPopup.vue +15 -12
- package/src/components/NotificationBell.vue +14 -5
- package/src/components/NotificationToast.vue +4 -1
- package/src/components/PluginLauncher.vue +19 -56
- package/src/components/RightSidebar.vue +13 -10
- package/src/components/SessionHistoryPanel.vue +33 -29
- package/src/components/SessionTabBar.vue +8 -10
- package/src/components/SettingsMcpTab.vue +43 -30
- package/src/components/SettingsModal.vue +21 -19
- package/src/components/SettingsReferenceDirsTab.vue +29 -24
- package/src/components/SettingsWorkspaceDirsTab.vue +32 -22
- package/src/components/SidebarHeader.vue +25 -4
- package/src/components/StackView.vue +4 -1
- package/src/components/SuggestionsPanel.vue +5 -2
- package/src/components/TodoExplorer.vue +26 -15
- package/src/components/ToolResultsPanel.vue +27 -13
- package/src/components/todo/TodoAddDialog.vue +17 -12
- package/src/components/todo/TodoEditDialog.vue +7 -2
- package/src/components/todo/TodoEditPanel.vue +15 -10
- package/src/components/todo/TodoKanbanView.vue +10 -5
- package/src/components/todo/TodoListView.vue +5 -2
- package/src/components/todo/TodoTableView.vue +5 -2
- package/src/composables/useAppApi.ts +9 -0
- package/src/composables/useDynamicFavicon.ts +172 -37
- package/src/composables/useEventListeners.ts +7 -8
- package/src/composables/useFaviconState.ts +13 -2
- package/src/composables/useFileSelection.ts +24 -6
- package/src/composables/useLayoutMode.ts +32 -0
- package/src/composables/useSessionHistory.ts +7 -17
- package/src/composables/useViewLayout.ts +20 -34
- package/src/lang/de.ts +536 -0
- package/src/lang/en.ts +558 -0
- package/src/lang/es.ts +543 -0
- package/src/lang/fr.ts +536 -0
- package/src/lang/ja.ts +536 -0
- package/src/lang/ko.ts +540 -0
- package/src/lang/pt-BR.ts +534 -0
- package/src/lang/zh.ts +537 -0
- package/src/lib/vue-i18n.ts +97 -0
- package/src/main.ts +2 -0
- package/src/plugins/canvas/View.vue +102 -186
- package/src/plugins/canvas/definition.ts +0 -8
- package/src/plugins/chart/Preview.vue +1 -1
- package/src/plugins/chart/View.vue +9 -4
- package/src/plugins/manageRoles/Preview.vue +4 -1
- package/src/plugins/manageRoles/View.vue +59 -43
- package/src/plugins/manageSkills/Preview.vue +8 -3
- package/src/plugins/manageSkills/View.vue +26 -22
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/manageSource/View.vue +73 -52
- package/src/plugins/markdown/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +24 -34
- package/src/plugins/presentHtml/Preview.vue +1 -1
- package/src/plugins/presentHtml/View.vue +7 -4
- package/src/plugins/presentMulmoScript/Preview.vue +1 -1
- package/src/plugins/presentMulmoScript/View.vue +36 -26
- package/src/plugins/scheduler/Preview.vue +7 -4
- package/src/plugins/scheduler/TasksTab.vue +53 -24
- package/src/plugins/scheduler/View.vue +28 -19
- package/src/plugins/scheduler/formatSchedule.ts +93 -0
- package/src/plugins/spreadsheet/Preview.vue +8 -3
- package/src/plugins/spreadsheet/View.vue +21 -12
- package/src/plugins/textResponse/Preview.vue +15 -58
- package/src/plugins/textResponse/View.vue +27 -7
- package/src/plugins/todo/Preview.vue +11 -6
- package/src/plugins/todo/View.vue +27 -13
- package/src/plugins/ui-image/ImagePreview.vue +6 -3
- package/src/plugins/ui-image/ImageView.vue +7 -4
- package/src/plugins/wiki/Preview.vue +5 -2
- package/src/plugins/wiki/View.vue +202 -81
- package/src/plugins/wiki/route.ts +112 -0
- package/src/router/guards.ts +42 -24
- package/src/router/index.ts +41 -26
- package/src/types/vue-i18n.d.ts +20 -0
- package/src/utils/agent/request.ts +19 -0
- package/src/utils/canvas/layoutMode.ts +26 -0
- package/src/utils/image/cacheBust.ts +16 -0
- package/src/utils/image/resolve.ts +16 -0
- package/src/utils/path/workspaceLinkRouter.ts +81 -0
- package/src/vite-env.d.ts +9 -0
- package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
- package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
- package/client/assets/index-Bm70FDU2.css +0 -1
- package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
- package/src/composables/useCanvasViewMode.ts +0 -121
- package/src/utils/canvas/viewMode.ts +0 -46
- /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
|
-
import
|
|
2
|
+
import { ReadStream, Stats, createReadStream, readFileSync, realpathSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { workspacePath } from "../../workspace/workspace.js";
|
|
5
5
|
import { statSafe, statSafeAsync, readDirSafeAsync, resolveWithinRoot, writeFileAtomic } from "../../utils/files/index.js";
|
|
6
6
|
import { errorMessage } from "../../utils/errors.js";
|
|
7
7
|
import { badRequest, notFound, sendError, serverError } from "../../utils/httpError.js";
|
|
8
|
+
import { getOptionalStringQuery } from "../../utils/request.js";
|
|
8
9
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
9
10
|
import { GitignoreFilter } from "../../utils/gitignore.js";
|
|
10
11
|
import { getCachedReferenceDirs } from "../../workspace/reference-dirs.js";
|
|
@@ -187,7 +188,7 @@ export function classify(filename: string): ContentKind {
|
|
|
187
188
|
// Cached realpath of the workspace. Computed once at module load so
|
|
188
189
|
// every request avoids the syscall. resolveWithinRoot needs an
|
|
189
190
|
// already-realpath'd root.
|
|
190
|
-
const workspaceReal =
|
|
191
|
+
const workspaceReal = realpathSync(workspacePath);
|
|
191
192
|
|
|
192
193
|
// Wraps the shared resolveWithinRoot helper with the additional
|
|
193
194
|
// hidden-dir traversal check (e.g. `.git/config`). `buildTreeAsync`
|
|
@@ -235,7 +236,7 @@ function resolveRefPath(prefixedPath: string): string | null {
|
|
|
235
236
|
|
|
236
237
|
let rootReal: string;
|
|
237
238
|
try {
|
|
238
|
-
rootReal =
|
|
239
|
+
rootReal = realpathSync(entry.hostPath);
|
|
239
240
|
} catch {
|
|
240
241
|
return null;
|
|
241
242
|
}
|
|
@@ -276,7 +277,7 @@ export function parseRange(header: string, size: number): ByteRange | null {
|
|
|
276
277
|
// RFC 7233 §2.1: "A Range request on a representation whose current
|
|
277
278
|
// length is 0 cannot be satisfied". We also need this guard at the
|
|
278
279
|
// top because the naive suffix-range math below produces `end = -1`
|
|
279
|
-
// for zero-byte files, which then crashes `
|
|
280
|
+
// for zero-byte files, which then crashes `createReadStream`
|
|
280
281
|
// with `ERR_OUT_OF_RANGE`.
|
|
281
282
|
if (size <= 0) return null;
|
|
282
283
|
const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
|
|
@@ -325,7 +326,7 @@ function applyRawSecurityHeaders(res: Response): void {
|
|
|
325
326
|
// If the read stream errors mid-flight (file deleted, disk error,
|
|
326
327
|
// permissions changed), surface a clean failure to the client instead
|
|
327
328
|
// of leaving the connection hanging.
|
|
328
|
-
function pipeWithErrorHandling(stream:
|
|
329
|
+
function pipeWithErrorHandling(stream: ReadStream, res: Response<ErrorResponse>): void {
|
|
329
330
|
stream.on("error", (err) => {
|
|
330
331
|
if (res.headersSent) {
|
|
331
332
|
res.destroy(err);
|
|
@@ -340,7 +341,7 @@ function pipeWithErrorHandling(stream: fs.ReadStream, res: Response<ErrorRespons
|
|
|
340
341
|
// the same security filters as the original sync implementation
|
|
341
342
|
// (hidden dirs, sensitive files, symlinks all rejected) and the same
|
|
342
343
|
// ordering (dirs before files, alphabetical within type). Uses
|
|
343
|
-
// `
|
|
344
|
+
// `promises` throughout so the walk never blocks the event loop,
|
|
344
345
|
// and fans out each directory's children in parallel via
|
|
345
346
|
// `Promise.all`.
|
|
346
347
|
//
|
|
@@ -493,7 +494,7 @@ router.get(API_ROUTES.files.tree, async (_req: Request<object, unknown, unknown,
|
|
|
493
494
|
// (no recursion) so the client can render the tree incrementally.
|
|
494
495
|
// `path` is optional; empty / missing = workspace root.
|
|
495
496
|
router.get(API_ROUTES.files.dir, async (req: Request<object, unknown, unknown, PathQuery>, res: Response<TreeNode | ErrorResponse>) => {
|
|
496
|
-
const relPath =
|
|
497
|
+
const relPath = getOptionalStringQuery(req, "path") ?? "";
|
|
497
498
|
|
|
498
499
|
// Reference directory branch — resolve against the registered ref dir
|
|
499
500
|
if (isRefPath(relPath)) {
|
|
@@ -567,8 +568,8 @@ interface PathQuery {
|
|
|
567
568
|
function resolveAndStatFile<T>(
|
|
568
569
|
req: Request<object, unknown, unknown, PathQuery>,
|
|
569
570
|
res: Response<T | ErrorResponse>,
|
|
570
|
-
): { relPath: string; absPath: string; stat:
|
|
571
|
-
const relPath =
|
|
571
|
+
): { relPath: string; absPath: string; stat: Stats } | null {
|
|
572
|
+
const relPath = getOptionalStringQuery(req, "path") ?? "";
|
|
572
573
|
if (!relPath) {
|
|
573
574
|
badRequest(res, "path required");
|
|
574
575
|
return null;
|
|
@@ -668,7 +669,7 @@ router.get(API_ROUTES.files.content, (req: Request<object, unknown, unknown, Pat
|
|
|
668
669
|
}
|
|
669
670
|
let content: string;
|
|
670
671
|
try {
|
|
671
|
-
content =
|
|
672
|
+
content = readFileSync(absPath, "utf-8");
|
|
672
673
|
} catch (err) {
|
|
673
674
|
res.status(500).json({ error: `Failed to read file: ${errorMessage(err)}` });
|
|
674
675
|
return;
|
|
@@ -779,12 +780,12 @@ router.get(API_ROUTES.files.raw, (req: Request<object, unknown, unknown, PathQue
|
|
|
779
780
|
res.status(206);
|
|
780
781
|
res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
781
782
|
res.setHeader("Content-Length", String(range.end - range.start + 1));
|
|
782
|
-
pipeWithErrorHandling(
|
|
783
|
+
pipeWithErrorHandling(createReadStream(absPath, { start: range.start, end: range.end }), res);
|
|
783
784
|
return;
|
|
784
785
|
}
|
|
785
786
|
|
|
786
787
|
res.setHeader("Content-Length", String(stat.size));
|
|
787
|
-
pipeWithErrorHandling(
|
|
788
|
+
pipeWithErrorHandling(createReadStream(absPath), res);
|
|
788
789
|
});
|
|
789
790
|
|
|
790
791
|
// ── Reference directory roots ───────────────────────────────────
|
|
@@ -7,8 +7,8 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
|
7
7
|
const router = Router();
|
|
8
8
|
|
|
9
9
|
async function callGemini(prompt: string): Promise<string> {
|
|
10
|
-
const
|
|
11
|
-
const response = await
|
|
10
|
+
const client = getGeminiClient();
|
|
11
|
+
const response = await client.models.generateContent({
|
|
12
12
|
model: "gemini-2.0-flash",
|
|
13
13
|
contents: [{ text: prompt }],
|
|
14
14
|
});
|
|
@@ -4,7 +4,7 @@ import { getSessionImageData } from "../../events/session-store/index.js";
|
|
|
4
4
|
import { generateGeminiImageContent, generateGeminiImageFromPrompt } from "../../utils/gemini.js";
|
|
5
5
|
import { errorMessage } from "../../utils/errors.js";
|
|
6
6
|
import { badRequest, serverError } from "../../utils/httpError.js";
|
|
7
|
-
import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath } from "../../utils/files/image-store.js";
|
|
7
|
+
import { saveImage, overwriteImage, loadImageBase64, stripDataUri, isImagePath, imagePathFromFilename } from "../../utils/files/image-store.js";
|
|
8
8
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
9
9
|
|
|
10
10
|
const router = Router();
|
|
@@ -148,14 +148,14 @@ router.post(API_ROUTES.image.upload, async (req: Request<object, unknown, Canvas
|
|
|
148
148
|
router.put(
|
|
149
149
|
API_ROUTES.image.update,
|
|
150
150
|
async (req: Request<{ filename: string }, unknown, CanvasImageBody>, res: Response<CanvasImageResponse | CanvasImageError>) => {
|
|
151
|
-
const relativePath =
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
badRequest(res, "imageData and path are required");
|
|
151
|
+
const relativePath = imagePathFromFilename(req.params.filename);
|
|
152
|
+
if (!relativePath) {
|
|
153
|
+
badRequest(res, "invalid image filename");
|
|
155
154
|
return;
|
|
156
155
|
}
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
const { imageData } = req.body;
|
|
157
|
+
if (!imageData) {
|
|
158
|
+
badRequest(res, "imageData is required");
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
const base64 = stripDataUri(imageData);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
|
-
import
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { WORKSPACE_PATHS } from "../../workspace/paths.js";
|
|
5
5
|
import { stripDataUri } from "../../utils/files/image-store.js";
|
|
6
|
+
import { writeJsonAtomic } from "../../utils/files/json.js";
|
|
6
7
|
import {
|
|
7
8
|
getFileObject,
|
|
8
9
|
initializeContextFromFiles,
|
|
@@ -26,6 +27,7 @@ import { slugify } from "../../utils/slug.js";
|
|
|
26
27
|
import { resolveWithinRoot } from "../../utils/files/safe.js";
|
|
27
28
|
import { errorMessage } from "../../utils/errors.js";
|
|
28
29
|
import { badRequest, notFound, serverError } from "../../utils/httpError.js";
|
|
30
|
+
import { getOptionalStringQuery } from "../../utils/request.js";
|
|
29
31
|
import { log } from "../../system/logger/index.js";
|
|
30
32
|
import { validateUpdateBeatBody, validateUpdateScriptBody } from "./mulmoScriptValidate.js";
|
|
31
33
|
import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
@@ -54,8 +56,8 @@ let storiesRealCache: string | null = null;
|
|
|
54
56
|
function ensureStoriesReal(): string | null {
|
|
55
57
|
if (storiesRealCache) return storiesRealCache;
|
|
56
58
|
try {
|
|
57
|
-
|
|
58
|
-
storiesRealCache =
|
|
59
|
+
mkdirSync(storiesDir, { recursive: true });
|
|
60
|
+
storiesRealCache = realpathSync(storiesDir);
|
|
59
61
|
return storiesRealCache;
|
|
60
62
|
} catch {
|
|
61
63
|
return null;
|
|
@@ -96,7 +98,7 @@ interface FilePathQuery {
|
|
|
96
98
|
filePath?: string;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
|
|
101
|
+
router.post(API_ROUTES.mulmoScript.save, async (req: Request<object, object, SaveMulmoScriptBody>, res: Response) => {
|
|
100
102
|
const { script, filename } = req.body;
|
|
101
103
|
|
|
102
104
|
if (!script || !Array.isArray(script.beats)) {
|
|
@@ -104,14 +106,14 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
|
|
|
104
106
|
return;
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
mkdirSync(storiesDir, { recursive: true });
|
|
108
110
|
|
|
109
111
|
const title = script.title || "untitled";
|
|
110
112
|
const slug = filename ? filename.replace(/\.json$/, "") : slugify(title);
|
|
111
113
|
const fname = `${slug}-${Date.now()}.json`;
|
|
112
114
|
const filePath = path.join(storiesDir, fname);
|
|
113
115
|
|
|
114
|
-
|
|
116
|
+
await writeJsonAtomic(filePath, script);
|
|
115
117
|
|
|
116
118
|
res.json({
|
|
117
119
|
data: { script, filePath: `stories/${fname}` },
|
|
@@ -120,7 +122,7 @@ router.post(API_ROUTES.mulmoScript.save, (req: Request<object, object, SaveMulmo
|
|
|
120
122
|
});
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unknown>, res: Response) => {
|
|
125
|
+
router.post(API_ROUTES.mulmoScript.updateBeat, async (req: Request<object, object, unknown>, res: Response) => {
|
|
124
126
|
const validation = validateUpdateBeatBody(req.body);
|
|
125
127
|
if (!validation.ok) {
|
|
126
128
|
badRequest(res, validation.error);
|
|
@@ -131,7 +133,7 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
|
|
|
131
133
|
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
132
134
|
if (!absoluteFilePath) return;
|
|
133
135
|
|
|
134
|
-
const script: MulmoScript = JSON.parse(
|
|
136
|
+
const script: MulmoScript = JSON.parse(readFileSync(absoluteFilePath, "utf-8"));
|
|
135
137
|
|
|
136
138
|
if (!Array.isArray(script.beats) || beatIndex >= script.beats.length) {
|
|
137
139
|
badRequest(res, "Invalid beatIndex");
|
|
@@ -139,12 +141,12 @@ router.post(API_ROUTES.mulmoScript.updateBeat, (req: Request<object, object, unk
|
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
script.beats[beatIndex] = beat as MulmoBeat;
|
|
142
|
-
|
|
144
|
+
await writeJsonAtomic(absoluteFilePath, script);
|
|
143
145
|
|
|
144
146
|
res.json({ ok: true });
|
|
145
147
|
});
|
|
146
148
|
|
|
147
|
-
router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, unknown>, res: Response) => {
|
|
149
|
+
router.post(API_ROUTES.mulmoScript.updateScript, async (req: Request<object, object, unknown>, res: Response) => {
|
|
148
150
|
const validation = validateUpdateScriptBody(req.body);
|
|
149
151
|
if (!validation.ok) {
|
|
150
152
|
badRequest(res, validation.error);
|
|
@@ -155,7 +157,7 @@ router.post(API_ROUTES.mulmoScript.updateScript, (req: Request<object, object, u
|
|
|
155
157
|
const absoluteFilePath = resolveStoryPath(filePath, res);
|
|
156
158
|
if (!absoluteFilePath) return;
|
|
157
159
|
|
|
158
|
-
|
|
160
|
+
await writeJsonAtomic(absoluteFilePath, updatedScript);
|
|
159
161
|
res.json({ ok: true });
|
|
160
162
|
});
|
|
161
163
|
|
|
@@ -170,7 +172,7 @@ router.get(API_ROUTES.mulmoScript.beatImage, async (req: Request<object, BeatIma
|
|
|
170
172
|
|
|
171
173
|
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
172
174
|
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
173
|
-
if (!
|
|
175
|
+
if (!existsSync(imagePath)) {
|
|
174
176
|
res.json({ image: null });
|
|
175
177
|
return;
|
|
176
178
|
}
|
|
@@ -197,13 +199,13 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
|
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
const outputPath = movieFilePath(context);
|
|
200
|
-
if (!
|
|
202
|
+
if (!existsSync(outputPath)) {
|
|
201
203
|
res.json({ moviePath: null });
|
|
202
204
|
return;
|
|
203
205
|
}
|
|
204
206
|
|
|
205
|
-
const movieMtime =
|
|
206
|
-
const sourceMtime =
|
|
207
|
+
const movieMtime = statSync(outputPath).mtimeMs;
|
|
208
|
+
const sourceMtime = statSync(absoluteFilePath).mtimeMs;
|
|
207
209
|
if (movieMtime < sourceMtime) {
|
|
208
210
|
res.json({ moviePath: null });
|
|
209
211
|
return;
|
|
@@ -216,7 +218,7 @@ router.get(API_ROUTES.mulmoScript.movieStatus, async (req: Request<object, Movie
|
|
|
216
218
|
});
|
|
217
219
|
|
|
218
220
|
function fileToDataUri(filePath: string, mimeType: string): string {
|
|
219
|
-
const data =
|
|
221
|
+
const data = readFileSync(filePath);
|
|
220
222
|
return `data:${mimeType};base64,${data.toString("base64")}`;
|
|
221
223
|
}
|
|
222
224
|
|
|
@@ -257,7 +259,7 @@ function resolveStoryPath(filePath: string, res: Response): string | null {
|
|
|
257
259
|
const resolved = resolveWithinRoot(storiesReal, relFromStories);
|
|
258
260
|
if (!resolved) {
|
|
259
261
|
const candidate = path.resolve(storiesReal, relFromStories);
|
|
260
|
-
if (!
|
|
262
|
+
if (!existsSync(candidate)) {
|
|
261
263
|
notFound(res, `File not found: ${filePath}`);
|
|
262
264
|
} else {
|
|
263
265
|
badRequest(res, "Invalid filePath");
|
|
@@ -373,12 +375,12 @@ router.get(API_ROUTES.mulmoScript.beatAudio, async (req: Request<object, BeatAud
|
|
|
373
375
|
filePath,
|
|
374
376
|
{
|
|
375
377
|
operation: "beat-audio",
|
|
376
|
-
onContextMissing: (
|
|
378
|
+
onContextMissing: (response) => response.json({ audio: null }),
|
|
377
379
|
},
|
|
378
380
|
async ({ context }) => {
|
|
379
381
|
const beat = context.studio.script.beats[beatIndex];
|
|
380
382
|
const audioPath = getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
|
|
381
|
-
if (!audioPath || !
|
|
383
|
+
if (!audioPath || !existsSync(audioPath)) {
|
|
382
384
|
res.json({ audio: null });
|
|
383
385
|
return;
|
|
384
386
|
}
|
|
@@ -422,7 +424,7 @@ router.post(
|
|
|
422
424
|
const beat = context.studio.script.beats[beatIndex];
|
|
423
425
|
const audioPath = context.studio.beats[beatIndex]?.audioFile ?? getBeatAudioPathOrUrl(beat.text ?? "", context, beat, context.lang);
|
|
424
426
|
|
|
425
|
-
if (!audioPath || !
|
|
427
|
+
if (!audioPath || !existsSync(audioPath)) {
|
|
426
428
|
// Logic-flow failure (not an exception) — emit a targeted
|
|
427
429
|
// log. Don't write raw `beat.text` into persistent logs —
|
|
428
430
|
// it's free-form user content and can contain sensitive
|
|
@@ -430,7 +432,7 @@ router.post(
|
|
|
430
432
|
log.error("generate-beat-audio", "audio was not generated", {
|
|
431
433
|
beatIndex,
|
|
432
434
|
audioPath,
|
|
433
|
-
exists: audioPath ?
|
|
435
|
+
exists: audioPath ? existsSync(audioPath) : false,
|
|
434
436
|
beatTextLength: typeof beat?.text === "string" ? beat.text.length : 0,
|
|
435
437
|
audioFilePresent: Boolean(context.studio.beats[beatIndex]?.audioFile),
|
|
436
438
|
});
|
|
@@ -475,7 +477,7 @@ router.post(API_ROUTES.mulmoScript.renderBeat, async (req: Request<object, objec
|
|
|
475
477
|
});
|
|
476
478
|
|
|
477
479
|
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
478
|
-
if (!
|
|
480
|
+
if (!existsSync(imagePath)) {
|
|
479
481
|
genError = "Image was not generated";
|
|
480
482
|
serverError(res, genError);
|
|
481
483
|
return;
|
|
@@ -542,7 +544,7 @@ router.post(API_ROUTES.mulmoScript.generateMovie, async (req: Request<object, ob
|
|
|
542
544
|
await movie(audioContext);
|
|
543
545
|
|
|
544
546
|
const outputPath = movieFilePath(audioContext);
|
|
545
|
-
if (!
|
|
547
|
+
if (!existsSync(outputPath)) {
|
|
546
548
|
genError = "Movie was not generated";
|
|
547
549
|
send({ type: "error", message: genError });
|
|
548
550
|
res.end();
|
|
@@ -594,7 +596,7 @@ router.get(
|
|
|
594
596
|
|
|
595
597
|
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
596
598
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
597
|
-
if (!
|
|
599
|
+
if (!existsSync(imagePath)) {
|
|
598
600
|
res.json({ image: null });
|
|
599
601
|
return;
|
|
600
602
|
}
|
|
@@ -613,10 +615,10 @@ router.post(API_ROUTES.mulmoScript.uploadBeatImage, async (req: Request<object,
|
|
|
613
615
|
|
|
614
616
|
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
615
617
|
const { imagePath } = getBeatPngImagePath(context, beatIndex);
|
|
616
|
-
|
|
618
|
+
mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
617
619
|
|
|
618
620
|
const base64 = stripDataUri(imageData);
|
|
619
|
-
|
|
621
|
+
writeFileSync(imagePath, Buffer.from(base64, "base64"));
|
|
620
622
|
|
|
621
623
|
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
622
624
|
});
|
|
@@ -647,7 +649,7 @@ router.post(
|
|
|
647
649
|
|
|
648
650
|
const index = Object.keys(images).indexOf(key);
|
|
649
651
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
650
|
-
|
|
652
|
+
mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
651
653
|
|
|
652
654
|
await generateReferenceImage({
|
|
653
655
|
context,
|
|
@@ -656,7 +658,7 @@ router.post(
|
|
|
656
658
|
image: imageEntry as MulmoImagePromptMedia,
|
|
657
659
|
force,
|
|
658
660
|
});
|
|
659
|
-
if (!
|
|
661
|
+
if (!existsSync(imagePath)) {
|
|
660
662
|
genError = "Character image was not generated";
|
|
661
663
|
serverError(res, genError);
|
|
662
664
|
return;
|
|
@@ -685,10 +687,10 @@ router.post(
|
|
|
685
687
|
|
|
686
688
|
await withStoryContext(res, filePath, {}, async ({ context }) => {
|
|
687
689
|
const imagePath = getReferenceImagePath(context, key, "png");
|
|
688
|
-
|
|
690
|
+
mkdirSync(path.dirname(imagePath), { recursive: true });
|
|
689
691
|
|
|
690
692
|
const base64 = stripDataUri(imageData);
|
|
691
|
-
|
|
693
|
+
writeFileSync(imagePath, Buffer.from(base64, "base64"));
|
|
692
694
|
|
|
693
695
|
res.json({ image: fileToDataUri(imagePath, "image/png") });
|
|
694
696
|
});
|
|
@@ -696,7 +698,7 @@ router.post(
|
|
|
696
698
|
);
|
|
697
699
|
|
|
698
700
|
router.get(API_ROUTES.mulmoScript.downloadMovie, (req: Request, res: Response) => {
|
|
699
|
-
const moviePath =
|
|
701
|
+
const moviePath = getOptionalStringQuery(req, "moviePath");
|
|
700
702
|
|
|
701
703
|
if (!moviePath) {
|
|
702
704
|
badRequest(res, "moviePath is required");
|
package/server/api/routes/pdf.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { realpathSync } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { Router, Request, Response } from "express";
|
|
4
4
|
import { marked } from "marked";
|
|
@@ -56,7 +56,7 @@ const MIME_BY_EXT: Record<string, string> = {
|
|
|
56
56
|
// Realpath of the workspace, resolved once at module load. Used to
|
|
57
57
|
// validate that image paths resolved relative to markdowns/ stay
|
|
58
58
|
// inside the workspace after symlink resolution.
|
|
59
|
-
const workspaceReal =
|
|
59
|
+
const workspaceReal = realpathSync(resolveWorkspacePath(""));
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* Inline local images as base64 data URIs so Puppeteer can render them.
|
|
@@ -80,10 +80,10 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const results = await Promise.all(
|
|
83
|
-
matches.map(async (
|
|
84
|
-
full:
|
|
85
|
-
prompt:
|
|
86
|
-
url: geminiOk ? await generateImageFile(
|
|
83
|
+
matches.map(async (match) => ({
|
|
84
|
+
full: match[0],
|
|
85
|
+
prompt: match[1],
|
|
86
|
+
url: geminiOk ? await generateImageFile(match[1]) : null,
|
|
87
87
|
})),
|
|
88
88
|
);
|
|
89
89
|
|
|
@@ -91,7 +91,7 @@ async function fillImagePlaceholders(markdown: string): Promise<string> {
|
|
|
91
91
|
// success rate even when most calls go through. The per-call
|
|
92
92
|
// error already lands at warn from generateImageFile's catch.
|
|
93
93
|
if (geminiOk) {
|
|
94
|
-
const failed = results.filter((
|
|
94
|
+
const failed = results.filter((result) => !result.url).length;
|
|
95
95
|
if (failed > 0) {
|
|
96
96
|
log.warn("present-document", "image generation had failures", {
|
|
97
97
|
failed,
|
|
@@ -261,10 +261,20 @@ router.post(
|
|
|
261
261
|
wrapPluginExecute((req) => executeForm(null as never, req.body)),
|
|
262
262
|
);
|
|
263
263
|
|
|
264
|
+
// 1×1 transparent PNG. Used as a placeholder so the canvas tool
|
|
265
|
+
// result can carry a stable file path from the moment the canvas
|
|
266
|
+
// is opened — client autosaves PUT-overwrite this same file, so the
|
|
267
|
+
// drawing survives page reload with zero client→server sync.
|
|
268
|
+
const BLANK_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
|
|
269
|
+
|
|
264
270
|
// openCanvas — drawing canvas
|
|
265
271
|
router.post(
|
|
266
272
|
API_ROUTES.plugins.canvas,
|
|
267
|
-
wrapPluginExecute(() =>
|
|
273
|
+
wrapPluginExecute(async () => {
|
|
274
|
+
const imagePath = await saveImage(BLANK_PNG_BASE64);
|
|
275
|
+
const base = await executeOpenCanvas();
|
|
276
|
+
return { ...base, data: { imageData: imagePath, prompt: "" } };
|
|
277
|
+
}),
|
|
268
278
|
);
|
|
269
279
|
|
|
270
280
|
// present3d — 3D visualization
|
|
@@ -7,7 +7,7 @@ import { API_ROUTES } from "../../../src/config/apiRoutes.js";
|
|
|
7
7
|
import { EVENT_TYPES } from "../../../src/types/events.js";
|
|
8
8
|
import { roleExists, deleteRole, saveRole } from "../../utils/files/roles-io.js";
|
|
9
9
|
|
|
10
|
-
const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((
|
|
10
|
+
const BUILTIN_IDS = new Set(BUILTIN_ROLES.map((role) => role.id));
|
|
11
11
|
|
|
12
12
|
const router = Router();
|
|
13
13
|
|
|
@@ -98,7 +98,7 @@ function saveRoleResult(input: ManageRolesInput, sessionId: string): Record<stri
|
|
|
98
98
|
const pluginsToSave = role.availablePlugins ?? [];
|
|
99
99
|
const roleToSave = {
|
|
100
100
|
...role,
|
|
101
|
-
availablePlugins: pluginsToSave.filter((
|
|
101
|
+
availablePlugins: pluginsToSave.filter((plugin) => plugin !== "switchRole"),
|
|
102
102
|
};
|
|
103
103
|
|
|
104
104
|
saveRole(role.id, roleToSave);
|
|
@@ -9,6 +9,8 @@ import { saveUserTasks } from "../../utils/files/user-tasks-io.js";
|
|
|
9
9
|
import { startChat } from "./agent.js";
|
|
10
10
|
import { log } from "../../system/logger/index.js";
|
|
11
11
|
import { SCHEDULER_ACTIONS, TASK_ACTIONS } from "../../../src/config/schedulerActions.js";
|
|
12
|
+
import { badRequest, notFound, serverError } from "../../utils/httpError.js";
|
|
13
|
+
import { errorMessage } from "../../utils/errors.js";
|
|
12
14
|
|
|
13
15
|
const router = Router();
|
|
14
16
|
|
|
@@ -77,7 +79,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
|
|
|
77
79
|
if (action === SCHEDULER_ACTIONS.createTask) {
|
|
78
80
|
const result = validateAndCreate(input);
|
|
79
81
|
if (result.kind === "error") {
|
|
80
|
-
res
|
|
82
|
+
badRequest(res, result.error);
|
|
81
83
|
return;
|
|
82
84
|
}
|
|
83
85
|
const tasks = loadUserTasks();
|
|
@@ -97,7 +99,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
|
|
|
97
99
|
const tasks = loadUserTasks();
|
|
98
100
|
const idx = tasks.findIndex((task) => task.id === taskId);
|
|
99
101
|
if (idx === -1) {
|
|
100
|
-
res
|
|
102
|
+
notFound(res, `task not found: ${taskId}`);
|
|
101
103
|
return;
|
|
102
104
|
}
|
|
103
105
|
const name = tasks[idx].name;
|
|
@@ -117,7 +119,7 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
|
|
|
117
119
|
const tasks = loadUserTasks();
|
|
118
120
|
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
119
121
|
if (!task) {
|
|
120
|
-
res
|
|
122
|
+
notFound(res, `task not found: ${taskId}`);
|
|
121
123
|
return;
|
|
122
124
|
}
|
|
123
125
|
const chatSessionId = crypto.randomUUID();
|
|
@@ -143,10 +145,10 @@ async function handleTaskAction(action: string, input: Record<string, unknown>,
|
|
|
143
145
|
return;
|
|
144
146
|
}
|
|
145
147
|
|
|
146
|
-
res
|
|
148
|
+
badRequest(res, `unknown task action: ${action}`);
|
|
147
149
|
} catch (err) {
|
|
148
|
-
log.error("scheduler", "task action failed", { error:
|
|
149
|
-
res
|
|
150
|
+
log.error("scheduler", "task action failed", { error: errorMessage(err) });
|
|
151
|
+
serverError(res, "Internal server error");
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
@@ -15,6 +15,7 @@ import { SESSION_ORIGINS } from "../../../src/types/session.js";
|
|
|
15
15
|
import { loadUserTasks, validateAndCreate, applyUpdate, withUserTaskLock } from "../../workspace/skills/user-tasks.js";
|
|
16
16
|
import { badRequest, notFound, serverError } from "../../utils/httpError.js";
|
|
17
17
|
import { errorMessage } from "../../utils/errors.js";
|
|
18
|
+
import { getOptionalStringQuery } from "../../utils/request.js";
|
|
18
19
|
import { log } from "../../system/logger/index.js";
|
|
19
20
|
import { startChat } from "./agent.js";
|
|
20
21
|
|
|
@@ -150,11 +151,12 @@ interface LogQuery {
|
|
|
150
151
|
|
|
151
152
|
router.get(API_ROUTES.scheduler.logs, async (req: Request<object, unknown, object, LogQuery>, res: Response<{ logs: TaskLogEntry[] }>) => {
|
|
152
153
|
const MAX_LIMIT = 500;
|
|
153
|
-
const
|
|
154
|
+
const rawLimitStr = getOptionalStringQuery(req, "limit");
|
|
155
|
+
const rawLimit = rawLimitStr ? parseInt(rawLimitStr, 10) : undefined;
|
|
154
156
|
const limit = Number.isFinite(rawLimit) && rawLimit! > 0 ? Math.min(rawLimit!, MAX_LIMIT) : undefined;
|
|
155
157
|
const logs = await getSchedulerLogs({
|
|
156
|
-
since:
|
|
157
|
-
taskId:
|
|
158
|
+
since: getOptionalStringQuery(req, "since"),
|
|
159
|
+
taskId: getOptionalStringQuery(req, "taskId"),
|
|
158
160
|
limit,
|
|
159
161
|
});
|
|
160
162
|
res.json({ logs });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Router, Request, Response } from "express";
|
|
2
|
-
import
|
|
2
|
+
import { realpathSync } from "fs";
|
|
3
3
|
import { readdir, stat } from "fs/promises";
|
|
4
4
|
import { readTextSafe } from "../../utils/files/safe.js";
|
|
5
5
|
import path from "path";
|
|
@@ -242,7 +242,7 @@ router.get(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res
|
|
|
242
242
|
const storiesDir = path.resolve(WORKSPACE_PATHS.stories);
|
|
243
243
|
let storiesReal: string;
|
|
244
244
|
try {
|
|
245
|
-
storiesReal =
|
|
245
|
+
storiesReal = realpathSync(storiesDir);
|
|
246
246
|
} catch {
|
|
247
247
|
return entry;
|
|
248
248
|
}
|
|
@@ -21,8 +21,8 @@ const CURSOR_PREFIX = "v1:";
|
|
|
21
21
|
* fall back to when an incoming cursor is malformed.
|
|
22
22
|
*/
|
|
23
23
|
export function encodeCursor(changeMs: number): string {
|
|
24
|
-
const
|
|
25
|
-
return `${CURSOR_PREFIX}${
|
|
24
|
+
const floored = Number.isFinite(changeMs) && changeMs > 0 ? Math.floor(changeMs) : 0;
|
|
25
|
+
return `${CURSOR_PREFIX}${floored}`;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -36,8 +36,8 @@ export function encodeCursor(changeMs: number): string {
|
|
|
36
36
|
export function parseCursor(raw: unknown): number {
|
|
37
37
|
if (typeof raw !== "string") return 0;
|
|
38
38
|
if (!raw.startsWith(CURSOR_PREFIX)) return 0;
|
|
39
|
-
const
|
|
40
|
-
return Number.isFinite(
|
|
39
|
+
const parsed = Number(raw.slice(CURSOR_PREFIX.length));
|
|
40
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -55,17 +55,17 @@ interface DeleteSkillResponse {
|
|
|
55
55
|
router.get(API_ROUTES.skills.list, async (_req: Request, res: Response<SkillsListResponse>) => {
|
|
56
56
|
const skills = await discoverSkills({ workspaceRoot: workspacePath });
|
|
57
57
|
res.json({
|
|
58
|
-
skills: skills.map((
|
|
59
|
-
name:
|
|
60
|
-
description:
|
|
61
|
-
source:
|
|
58
|
+
skills: skills.map((skill) => ({
|
|
59
|
+
name: skill.name,
|
|
60
|
+
description: skill.description,
|
|
61
|
+
source: skill.source,
|
|
62
62
|
})),
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
router.get(API_ROUTES.skills.detail, async (req: Request<{ name: string }>, res: Response<SkillDetailResponse | ErrorResponse>) => {
|
|
67
67
|
const skills = await discoverSkills({ workspaceRoot: workspacePath });
|
|
68
|
-
const skill = skills.find((
|
|
68
|
+
const skill = skills.find((candidate) => candidate.name === req.params.name);
|
|
69
69
|
if (!skill) {
|
|
70
70
|
notFound(res, `skill not found: ${req.params.name}`);
|
|
71
71
|
return;
|
|
@@ -312,7 +312,7 @@ async function handleRegister(body: ManageSourceBody, res: Response<ManageSource
|
|
|
312
312
|
async function handleRemove(body: ManageSourceBody, res: Response<ManageSourceSuccess | ErrorResponse>): Promise<void> {
|
|
313
313
|
const slug = typeof body.slug === "string" ? body.slug.trim() : "";
|
|
314
314
|
if (!isValidSlug(slug)) {
|
|
315
|
-
res
|
|
315
|
+
badRequest(res, "slug is required and must be a valid slug");
|
|
316
316
|
return;
|
|
317
317
|
}
|
|
318
318
|
const removed = await deleteSource(workspacePath, slug);
|
|
@@ -525,8 +525,8 @@ async function resolveCategories(parsed: ParsedRegisterBody): Promise<{ categori
|
|
|
525
525
|
|
|
526
526
|
function isHttpUrl(raw: string): boolean {
|
|
527
527
|
try {
|
|
528
|
-
const
|
|
529
|
-
return
|
|
528
|
+
const url = new URL(raw);
|
|
529
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
530
530
|
} catch {
|
|
531
531
|
return false;
|
|
532
532
|
}
|
|
@@ -242,7 +242,7 @@ export function handleRemoveLabel(items: TodoItem[], input: TodosActionInput): T
|
|
|
242
242
|
|
|
243
243
|
export function handleListLabels(items: TodoItem[]): TodosActionResult {
|
|
244
244
|
const inventory = listLabelsWithCount(items);
|
|
245
|
-
const summary = inventory.map((
|
|
245
|
+
const summary = inventory.map((entry) => `${entry.label} (${entry.count})`).join(", ");
|
|
246
246
|
const message = inventory.length === 0 ? "No labels in use" : `${inventory.length} label(s) in use: ${summary}`;
|
|
247
247
|
return {
|
|
248
248
|
kind: "success",
|