import { Router, Request, Response } from "express"; import { getSessionQuery } from "../../utils/request.js"; import { latestToolResult } from "../../events/session-store/index.js"; import { TOOL_NAMES } from "../../../src/config/toolNames.js"; import { META as PRESENT_FORM_META } from "../../../src/plugins/presentForm/meta.js"; import { META as PRESENT_COLLECTION_META } from "../../../src/plugins/presentCollection/meta.js"; import type { ToolContext } from "gui-chat-protocol"; import { executeMindMap } from "@gui-chat-plugin/mindmap"; import { executeSpreadsheet, type SpreadsheetArgs } from "../../../src/plugins/spreadsheet/definition.js"; import { executeQuiz } from "@mulmochat-plugin/quiz"; import { executeForm } from "../../../src/plugins/presentForm/plugin.js"; import { executePresentCollection } from "../../../src/plugins/presentCollection/plugin.js"; import type { PresentCollectionArgs } from "../../../src/plugins/presentCollection/types.js"; import { loadCollection, validateCollectionRecords } from "../../workspace/collections/index.js"; import { defangForPrompt } from "@mulmoclaude/core/collection"; import { executeOpenCanvas } from "../../../src/plugins/canvas/definition.js"; import { executePresent3D } from "@gui-chat-plugin/present3d"; import { executeMapControl } from "@gui-chat-plugin/google-map"; import { errorMessage } from "../../utils/errors.js"; import { badRequest, serverError } from "../../utils/httpError.js"; import { saveImage } from "../../utils/files/image-store.js"; import { fillMarkdownImagePlaceholders } from "../../utils/files/markdown-image-fill.js"; import { saveMarkdown } from "../../utils/files/markdown-store.js"; import { documentExists, overwriteDocument, resolveDocumentPath } from "../../utils/files/document-store.js"; import { saveSpreadsheet, overwriteSpreadsheet, isSpreadsheetPath } from "../../utils/files/spreadsheet-store.js"; import { API_ROUTES } from "../../../src/config/apiRoutes.js"; import { bindRoute } from "../../utils/router.js"; import { collectPluginMetaDiagnostics } from "../../plugins/diagnostics.js"; import { log } from "../../system/logger/index.js"; import { previewSnippet } from "../../utils/logPreview.js"; import { publishFileChange } from "../../events/file-change.js"; import { isAblated } from "../../system/env.js"; const router = Router(); interface PluginErrorResponse { message: string; } // Wraps a plugin's `execute*` invocation in an Express handler. Each // plugin route used to inline the same try/catch + 500 response shell; // this collapses them to one line per route. // // The callback receives the Express request and is responsible for // pulling whatever it needs out of `req.body` and forwarding it to // the plugin's execute function. `req.body` is `any` by Express // default and each plugin's execute function does its own runtime // validation — matching the behavior of the inline handlers this // replaces. // // Logging policy (#779): a single entry/success/error log here covers // every route that adopts this wrapper (mindmap / quiz / form / // canvas / present3d / presentSpreadsheet). Without it, plugin // errors used to land as a generic 500 response with no server-log // trace — exactly the silent-failure pattern the audit is closing. // eslint-disable-next-line @typescript-eslint/no-explicit-any function wrapPluginExecute( execute: (req: Request) => Promise, ): (req: Request, res: Response) => Promise { return async (req, res) => { // `req.path` here is the absolute path under the router's mount — // useful as a per-call identifier without having to thread the // plugin name through every call site. log.info("plugins", "execute: start", { route: req.path }); try { const result = await execute(req); log.info("plugins", "execute: ok", { route: req.path }); res.json(result); } catch (err) { log.error("plugins", "execute: threw", { route: req.path, error: errorMessage(err) }); res.status(500).json({ message: errorMessage(err) }); } }; } // presentDocument — fills image placeholders via Gemini if API key is available interface PresentDocumentBody { title: string; markdown?: string | undefined; filenamePrefix?: string | undefined; path?: string | undefined; } interface PresentDocumentSuccess { message: string; instructions: string; title: string; data: { markdown: string; docPath: string; filenamePrefix?: string | undefined }; } interface PresentDocumentError { error: string; } const PRESENT_DOCUMENT_ACK = "Acknowledge that the document has been presented to the user."; const isNonEmpty = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; /** `path` form — present an existing `.md` in place: a document this app * wrote, a file in the workspace, or an absolute path elsewhere on disk. * Nothing is written: `data.markdown` / `data.docPath` carry the caller's path * verbatim, so the View loads THAT file and its Apply / task-checkbox saves * (PUT /api/markdown/update) overwrite it rather than a fresh copy. */ async function presentExistingDocument(res: Response, path: string, title: string): Promise { if (resolveDocumentPath(path) === null) { log.warn("plugins", "presentDocument: invalid path", { pathPreview: previewSnippet(path) }); badRequest(res, "path must be a .md file path, without `.` / `..` segments"); return; } if (!(await documentExists(path))) { log.warn("plugins", "presentDocument: path not found", { pathPreview: previewSnippet(path) }); badRequest(res, `No document exists at ${path}`); return; } log.info("plugins", "presentDocument: presented existing", { pathPreview: previewSnippet(path) }); res.json({ message: `Presented existing document at ${path}`, instructions: PRESENT_DOCUMENT_ACK, title, data: { markdown: path, docPath: path } }); } /** `markdown` form — fill image placeholders, then save under a fresh * `artifacts/documents///…` path. */ async function saveAndPresentDocument(res: Response, body: PresentDocumentBody): Promise { const { title, markdown, filenamePrefix } = body; if (!isNonEmpty(markdown)) { log.warn("plugins", "presentDocument: missing markdown and path"); badRequest(res, "provide either `markdown` or `path`"); return; } // A missing prefix is no longer a 400: `path` made `filenamePrefix` // conditional, and JSON Schema can't say "required only with `markdown`", so // a caller reading `required` can legitimately omit it. `saveMarkdown` // slugifies to "document" on empty — the same default the shared plugin core // applies (`filenamePrefix ?? "document"`), so both hosts behave alike. const filledMarkdown = await fillMarkdownImagePlaceholders(markdown); const markdownPath = await saveMarkdown(filledMarkdown, filenamePrefix ?? ""); log.info("plugins", "presentDocument: ok", { markdownPath, bytes: filledMarkdown.length }); res.json({ message: `Saved markdown to ${markdownPath}`, instructions: PRESENT_DOCUMENT_ACK, title, data: { markdown: markdownPath, docPath: markdownPath, filenamePrefix }, }); } bindRoute( router, API_ROUTES.markdown.create, async (req: Request, res: Response) => { const { title, markdown, filenamePrefix, path: documentPath } = req.body; log.info("plugins", "presentDocument: start", { titlePreview: typeof title === "string" ? previewSnippet(title) : undefined, prefixPreview: typeof filenamePrefix === "string" ? previewSnippet(filenamePrefix) : undefined, markdownBytes: typeof markdown === "string" ? markdown.length : undefined, pathPreview: typeof documentPath === "string" ? previewSnippet(documentPath) : undefined, }); // `markdown` and `path` are mutually exclusive — same contract as // presentHtml's `html` / `path`. Reject both-set rather than letting // one silently win. if (isNonEmpty(documentPath) && isNonEmpty(markdown)) { badRequest(res, "provide either `markdown` or `path`, not both"); return; } if (isNonEmpty(documentPath)) { await presentExistingDocument(res, documentPath, title); return; } await saveAndPresentDocument(res, req.body); }, ); // Update markdown file on disk (user edits in View). Body carries the // workspace-relative path verbatim (e.g. // `artifacts/documents/2026/04/abc-123.md`) so the route doesn't have // to reconstruct one from a basename — required after #764 sharded // `artifacts/documents` by YYYY/MM. interface UpdateMarkdownBody { relativePath: string; markdown: string; } interface UpdateMarkdownResponse { path: string; } interface UpdateMarkdownError { error: string; } bindRoute( router, API_ROUTES.markdown.update, async (req: Request, res: Response) => { const { relativePath, markdown } = req.body; log.info("plugins", "updateMarkdown: start", { pathPreview: typeof relativePath === "string" ? previewSnippet(relativePath) : undefined, bytes: typeof markdown === "string" ? markdown.length : undefined, }); if (!markdown) { log.warn("plugins", "updateMarkdown: missing markdown"); badRequest(res, "markdown is required"); return; } if (!relativePath || resolveDocumentPath(relativePath) === null) { log.warn("plugins", "updateMarkdown: invalid relativePath", { pathPreview: typeof relativePath === "string" ? previewSnippet(relativePath) : undefined, }); badRequest(res, "invalid markdown relativePath"); return; } // Overwrite only — a path that no longer exists means the View is stale or // the path was wrong, which is a client error, not a server fault. Checked // here so it surfaces as 400 (matching presentHtml's update route) instead // of falling into the catch below as a 500. `overwriteDocument` re-checks: // between this line and the write, the file can still vanish. if (!(await documentExists(relativePath))) { log.warn("plugins", "updateMarkdown: no document at path", { pathPreview: previewSnippet(relativePath) }); badRequest(res, `no document exists at ${relativePath}`); return; } try { await overwriteDocument(relativePath, markdown); log.info("plugins", "updateMarkdown: ok", { pathPreview: previewSnippet(relativePath), bytes: markdown.length }); void publishFileChange(relativePath); res.json({ path: relativePath }); } catch (err) { log.error("plugins", "updateMarkdown: threw", { pathPreview: previewSnippet(relativePath), error: errorMessage(err) }); serverError(res, errorMessage(err)); } }, ); // Every `ToolContext` field is optional, so `{}` is a valid context carrying no // client-side state — what the server has, and what `runtime-plugin.ts` already // passes. `null` is not: `executeMindMap` reads `context.currentResult` unguarded // and throws on it. Frozen because one instance serves every request. const SERVER_TOOL_CONTEXT: ToolContext = Object.freeze({}); /** The context for a tool whose next call edits what its previous one produced. * * A plugin's `execute()` never runs in the client here — every call arrives on * this router — so a context without `currentResult` left `add_node` with no * map to add to (#2754). The session id rides on the query string already: the * MCP bridge appends `?session=` to every request. * * Falls back to the empty context, which is what this route passed before and * is still valid: no session, no previous result, or a first call all mean the * plugin is creating rather than editing. */ export function sessionToolContext(req: Request, toolName: string): ToolContext { const currentResult = latestToolResult(getSessionQuery(req), toolName); return currentResult ? { currentResult } : SERVER_TOOL_CONTEXT; } // presentSpreadsheet — validate, then save sheets to disk bindRoute( router, API_ROUTES.spreadsheet.create, wrapPluginExecute(async (req) => { const result = await executeSpreadsheet(req.body); if (!Array.isArray(result.data.sheets)) { throw new Error("Expected sheets array from executeSpreadsheet"); } const sheetsPath = await saveSpreadsheet(result.data.sheets); return { ...result, data: { ...result.data, sheets: sheetsPath } }; }), ); // Update spreadsheet file on disk (user edits in View). Body carries // the workspace-relative path so the route is symmetric with // updateMarkdown / image.update — see #764. interface UpdateSpreadsheetBody { relativePath: string; sheets: unknown[]; } interface UpdateSpreadsheetResponse { path: string; } interface UpdateSpreadsheetError { error: string; } bindRoute( router, API_ROUTES.spreadsheet.update, async (req: Request, res: Response) => { const { relativePath, sheets } = req.body; log.info("plugins", "updateSpreadsheet: start", { pathPreview: typeof relativePath === "string" ? previewSnippet(relativePath) : undefined, sheetCount: Array.isArray(sheets) ? sheets.length : undefined, }); if (!Array.isArray(sheets)) { log.warn("plugins", "updateSpreadsheet: sheets not an array"); badRequest(res, "sheets must be an array"); return; } if (!relativePath || !isSpreadsheetPath(relativePath)) { log.warn("plugins", "updateSpreadsheet: invalid relativePath", { pathPreview: typeof relativePath === "string" ? previewSnippet(relativePath) : undefined, }); badRequest(res, "invalid spreadsheet relativePath"); return; } try { await overwriteSpreadsheet(relativePath, sheets); log.info("plugins", "updateSpreadsheet: ok", { pathPreview: previewSnippet(relativePath), sheetCount: sheets.length }); res.json({ path: relativePath }); } catch (err) { log.error("plugins", "updateSpreadsheet: threw", { pathPreview: previewSnippet(relativePath), error: errorMessage(err) }); serverError(res, errorMessage(err)); } }, ); // createMindMap — uses package execute for node layout computation router.post( API_ROUTES.plugins.mindmap, wrapPluginExecute[1]>((req) => executeMindMap(sessionToolContext(req, TOOL_NAMES.createMindMap), req.body)), ); // putQuestions — quiz router.post( API_ROUTES.plugins.quiz, wrapPluginExecute[1]>((req) => executeQuiz(sessionToolContext(req, TOOL_NAMES.putQuestions), req.body)), ); // presentForm — form bindRoute( router, API_ROUTES.form.dispatch, wrapPluginExecute[1]>((req) => executeForm(sessionToolContext(req, PRESENT_FORM_META.toolName), req.body)), ); // presentCollection — render a collection (or one item) as an inline, // editable chat card. The View mounts CollectionView, which fetches + // mutates live workspace state via the existing /api/collections routes. // // On top of the isomorphic executor we run a server-side validation pass: // a malformed record is silently skipped at read time, so without this a // bad file just vanishes. We append any problems to `instructions` (which // the LLM reads) so the model — which is told to call presentCollection // after every write — fixes the file instead of losing the record. // `defangForPrompt` (shared with the client Repair button via // `@mulmoclaude/core/collection`) strips markup / escape sequences, collapses // whitespace, and clips — so record-controlled text in a validation issue (a // filename, id, or enum value) can't be read as instructions once appended to // the LLM-facing result. async function dispatchPresentCollection(req: Request) { const result = await executePresentCollection(sessionToolContext(req, PRESENT_COLLECTION_META.toolName), req.body); const slug = result.data?.collectionSlug; if (!slug) return result; // error result (no slug) — nothing to validate if (isAblated("validation")) return result; // evaluation-only: issue reporting ablated // Validation is best-effort: it must never turn a successful present into a // 500, so swallow its failures and just present without the warning. try { const collection = await loadCollection(slug); if (!collection) return result; // bad slug surfaces as the View's not-found state const issues = await validateCollectionRecords(collection); if (issues.length === 0) return result; log.warn("plugins", "presentCollection: record issues", { slug, count: issues.length }); const lines = issues.map((issue) => `- ${defangForPrompt(issue.file)}: ${defangForPrompt(issue.problem)}`).join("\n"); const warning = `\n\n⚠️ ${issues.length} record file(s) have data problems and may be missing from the view. Fix each (Read → correct → Write):\n${lines}`; return { ...result, instructions: `${result.instructions ?? ""}${warning}` }; } catch (err) { log.warn("plugins", "presentCollection: validation skipped", { slug, error: errorMessage(err) }); return result; } } bindRoute(router, API_ROUTES.presentCollection.dispatch, wrapPluginExecute(dispatchPresentCollection)); // 1×1 transparent PNG. Used as a placeholder so the canvas tool // result can carry a stable file path from the moment the canvas // is opened — client autosaves PUT-overwrite this same file, so the // drawing survives page reload with zero client→server sync. const BLANK_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII="; // openCanvas — drawing canvas bindRoute( router, API_ROUTES.canvas.dispatch, wrapPluginExecute(async () => { const imagePath = await saveImage(BLANK_PNG_BASE64); const base = await executeOpenCanvas(imagePath); return { ...base, data: { imageData: imagePath, prompt: "" } }; }), ); // present3d — 3D visualization router.post( API_ROUTES.plugins.present3d, wrapPluginExecute[1]>((req) => executePresent3D(sessionToolContext(req, TOOL_NAMES.present3D), req.body)), ); // mapControl — Google Map (showLocation / Places / Directions etc.) // from `@gui-chat-plugin/google-map`. The package's `executeMapControl` // returns the action descriptor; the rendered View — mounted host-side // from `App.vue` — performs the actual Google Maps JS calls and // receives the API key as a prop sourced from `AppSettings`. router.post( API_ROUTES.plugins.googleMap, wrapPluginExecute[1]>((req) => executeMapControl(sessionToolContext(req, TOOL_NAMES.mapControl), req.body)), ); // META aggregator diagnostics — boot-time host/plugin or plugin/plugin // key collisions. The frontend fetches this once at mount so a tab // that opens after the boot-time `publishNotification` fired still // gets the warning. Empty array when clean. router.get(API_ROUTES.plugins.diagnostics, (_req, res) => { res.json({ diagnostics: collectPluginMetaDiagnostics() }); }); export default router;