From f656e14ef752cff1ea1eb15afa38a467edefc61d Mon Sep 17 00:00:00 2001
From: willchen96
Date: Sun, 17 May 2026 01:57:19 +0800
Subject: [PATCH 01/21] docs: add security reporting guidance
---
CONTRIBUTING.md | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 04be698..5fbd2ed 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,6 +20,12 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
- why
- testing
+## Security
+
+Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
+
+We will aim to respond promptly and coordinate a disclosure timeline with you.
+
## Local Development
Backend:
From a5fe6d6e04fc9bd8fbef5a0058f7a8d38e27148a Mon Sep 17 00:00:00 2001
From: willchen96
Date: Sat, 4 Jul 2026 23:24:31 +0800
Subject: [PATCH 02/21] Sync workflow, asst extra input, excel + ppt support
and modal updates
---
CONTRIBUTING.md | 13 +
backend/bun.lock | 120 +-
.../20260613_05_workflows_overview_rpc.sql | 25 +-
.../20260625_01_workflow_metadata.sql | 120 +
...29_01_workflow_open_source_submissions.sql | 39 +
backend/package-lock.json | 14 +-
backend/package.json | 2 +
backend/schema.sql | 59 +-
backend/src/lib/builtinWorkflows.ts | 76 -
backend/src/lib/chat/citations.ts | 310 ++
backend/src/lib/chat/contextBuilders.ts | 583 +++
backend/src/lib/chat/index.ts | 8 +
backend/src/lib/chat/prompts.ts | 82 +
backend/src/lib/chat/streaming.ts | 553 ++
.../tools}/courtlistenerTools.ts | 0
backend/src/lib/chat/tools/documentOps.ts | 1813 +++++++
backend/src/lib/chat/tools/toolDispatcher.ts | 1896 +++++++
backend/src/lib/chat/tools/toolSchemas.ts | 471 ++
backend/src/lib/chat/types.ts | 153 +
backend/src/lib/chatTools.ts | 4590 -----------------
backend/src/lib/documentTypes.ts | 60 +
backend/src/lib/llm/tools.ts | 120 +-
backend/src/lib/officeText.ts | 53 +
backend/src/lib/spreadsheet.ts | 100 +
backend/src/lib/systemWorkflows.ts | 1596 ++++++
backend/src/lib/userDataCleanup.ts | 4 +
backend/src/lib/userDataExport.ts | 7 +
backend/src/middleware/auth.ts | 14 +
backend/src/routes/chat.ts | 105 +-
backend/src/routes/documents.ts | 72 +-
backend/src/routes/downloads.ts | 12 +-
backend/src/routes/projectChat.ts | 111 +-
backend/src/routes/projects.ts | 117 +-
backend/src/routes/tabular.ts | 119 +-
backend/src/routes/user.ts | 17 +
backend/src/routes/workflows.ts | 439 +-
frontend/package-lock.json | 171 +-
frontend/package.json | 6 +
.../src/app/(pages)/account/api-keys/page.tsx | 2 +-
.../app/(pages)/account/connectors/page.tsx | 170 +-
frontend/src/app/(pages)/account/page.tsx | 6 +-
.../app/(pages)/account/privacy-data/page.tsx | 4 +-
.../src/app/(pages)/account/security/page.tsx | 14 +-
.../[id]/assistant/chat/[chatId]/page.tsx | 97 +-
.../projects/[id]/tabular-reviews/page.tsx | 53 +-
.../src/app/(pages)/tabular-reviews/page.tsx | 167 +-
.../app/components/assistant/AddDocButton.tsx | 2 +-
.../components/assistant/AskInputPopup.tsx | 730 +++
.../components/assistant/AssistantMessage.tsx | 1816 +------
.../assistant/AssistantSidePanel.tsx | 8 +-
.../app/components/assistant/CaseLawPanel.tsx | 12 +-
.../app/components/assistant/ChatInput.tsx | 20 +-
.../src/app/components/assistant/ChatView.tsx | 180 +-
.../src/app/components/assistant/EditCard.tsx | 32 +-
.../assistant/SelectAssistantProjectModal.tsx | 18 +-
.../app/components/assistant/UserMessage.tsx | 28 +-
.../modals/credits-exhausted-modal.tsx | 81 -
.../components/modals/delete-chats-modal.tsx | 97 -
.../components/modals/simple-link-dialog.tsx | 90 -
.../components/projects/DocumentSidePanel.tsx | 38 +-
.../components/projects/NewProjectModal.tsx | 292 +-
.../projects/ProjectAssistantTable.tsx | 59 +-
.../projects/ProjectDetailsModal.tsx | 169 +-
.../projects/ProjectDocumentsView.tsx | 18 +-
.../components/projects/ProjectExplorer.tsx | 9 +-
.../components/projects/ProjectPageParts.tsx | 44 +-
.../projects/ProjectReviewsTable.tsx | 103 +-
.../components/projects/ProjectWorkspace.tsx | 20 +-
.../components/projects/ProjectsOverview.tsx | 268 +-
.../components/shared/AddDocumentsModal.tsx | 316 --
.../components/shared/AddProjectDocsModal.tsx | 261 -
.../components/shared/ApiKeyMissingModal.tsx | 45 -
.../src/app/components/shared/AppSidebar.tsx | 2 +-
.../app/components/shared/ConfirmPopup.tsx | 117 -
.../src/app/components/shared/DocPanel.tsx | 559 --
.../src/app/components/shared/DocView.tsx | 601 ---
.../app/components/shared/DocViewModal.tsx | 101 -
.../app/components/shared/DocumentCard.tsx | 87 -
.../src/app/components/shared/DocxView.tsx | 505 --
.../app/components/shared/EmailPillInput.tsx | 117 -
.../app/components/shared/FileDirectory.tsx | 517 +-
.../app/components/shared/FileTypeIcon.tsx | 49 +
.../app/components/shared/MfaLoginGate.tsx | 2 +-
.../shared/MfaVerificationPopup.tsx | 294 --
frontend/src/app/components/shared/Modal.tsx | 208 -
.../app/components/shared/OwnerOnlyModal.tsx | 57 -
.../src/app/components/shared/PeopleModal.tsx | 362 --
.../components/shared/PreResponseWrapper.tsx | 74 -
.../app/components/shared/ProjectPicker.tsx | 91 -
.../app/components/shared/RelevantQuotes.tsx | 297 --
.../app/components/shared/RenameableTitle.tsx | 91 -
.../src/app/components/shared/RowActions.tsx | 15 +-
.../app/components/shared/SidebarChatItem.tsx | 4 +-
.../app/components/shared/TablePrimitive.tsx | 32 +-
.../shared/UploadNewVersionModal.tsx | 123 -
.../app/components/shared/WarningPopup.tsx | 117 -
.../components/shared/highlightDocxQuote.ts | 130 -
.../app/components/shared/highlightQuote.ts | 124 -
frontend/src/app/components/shared/types.ts | 181 +-
.../app/components/shared/useDirectoryData.ts | 14 +-
.../app/components/tabular/AddColumnModal.tsx | 13 +-
.../app/components/tabular/AddNewTRModal.tsx | 488 --
.../app/components/tabular/TRChatPanel.tsx | 16 +-
.../app/components/tabular/TRSidePanel.tsx | 10 +-
.../src/app/components/tabular/TRTable.tsx | 160 +-
.../components/tabular/TabularReviewView.tsx | 80 +-
.../components/tabular/prompt-generator.ts | 60 -
.../workflows/DisplayWorkflowModal.tsx | 577 ---
.../components/workflows/NewWorkflowModal.tsx | 555 +-
.../workflows/ShareWorkflowModal.tsx | 156 -
.../workflows/WFColumnViewModal.tsx | 20 +-
.../workflows/WFEditColumnModal.tsx | 17 +-
.../workflows/WorkflowDetailPage.tsx | 329 +-
.../workflows/WorkflowDetailsModal.tsx | 190 -
.../app/components/workflows/WorkflowList.tsx | 247 +-
.../workflows/WorkflowPickerContent.tsx | 186 +-
.../workflows/WorkflowPickerModal.tsx | 59 +-
.../workflows/WorkflowPromptEditor.tsx | 6 +
.../components/workflows/builtinWorkflows.ts | 1246 -----
.../src/app/components/workflows/practices.ts | 2 +-
frontend/src/app/error.tsx | 10 +-
frontend/src/app/global-error.tsx | 32 +-
frontend/src/app/globals.css | 66 +-
frontend/src/app/hooks/useAssistantChat.ts | 379 +-
frontend/src/app/hooks/useDocumentVersions.ts | 98 -
frontend/src/app/hooks/useFetchSingleDoc.ts | 20 +-
.../src/app/lib/documentUploadValidation.ts | 16 +-
frontend/src/app/lib/mikeApi.ts | 84 +-
frontend/src/app/verify-mfa/page.tsx | 2 +-
frontend/src/components/ui/badge.tsx | 46 -
frontend/src/components/ui/pill-button.tsx | 50 +
.../src/components/ui/text-search-widget.tsx | 94 -
frontend/src/lib/auth.ts | 54 -
frontend/src/lib/fileConverter.ts | 65 -
frontend/src/lib/label.ts | 28 -
frontend/src/lib/slug.ts | 57 -
frontend/src/lib/types.ts | 81 -
frontend/src/scripts/convert-courts-to-ts.js | 89 -
frontend/src/types/luckyexcel.d.ts | 30 +
scripts/build-workflows.js | 300 ++
140 files changed, 13508 insertions(+), 16700 deletions(-)
create mode 100644 backend/migrations/20260625_01_workflow_metadata.sql
create mode 100644 backend/migrations/20260629_01_workflow_open_source_submissions.sql
delete mode 100644 backend/src/lib/builtinWorkflows.ts
create mode 100644 backend/src/lib/chat/citations.ts
create mode 100644 backend/src/lib/chat/contextBuilders.ts
create mode 100644 backend/src/lib/chat/index.ts
create mode 100644 backend/src/lib/chat/prompts.ts
create mode 100644 backend/src/lib/chat/streaming.ts
rename backend/src/lib/{legalSourcesTools => chat/tools}/courtlistenerTools.ts (100%)
create mode 100644 backend/src/lib/chat/tools/documentOps.ts
create mode 100644 backend/src/lib/chat/tools/toolDispatcher.ts
create mode 100644 backend/src/lib/chat/tools/toolSchemas.ts
create mode 100644 backend/src/lib/chat/types.ts
delete mode 100644 backend/src/lib/chatTools.ts
create mode 100644 backend/src/lib/documentTypes.ts
create mode 100644 backend/src/lib/officeText.ts
create mode 100644 backend/src/lib/spreadsheet.ts
create mode 100644 backend/src/lib/systemWorkflows.ts
create mode 100644 frontend/src/app/components/assistant/AskInputPopup.tsx
delete mode 100644 frontend/src/app/components/modals/credits-exhausted-modal.tsx
delete mode 100644 frontend/src/app/components/modals/delete-chats-modal.tsx
delete mode 100644 frontend/src/app/components/modals/simple-link-dialog.tsx
delete mode 100644 frontend/src/app/components/shared/AddDocumentsModal.tsx
delete mode 100644 frontend/src/app/components/shared/AddProjectDocsModal.tsx
delete mode 100644 frontend/src/app/components/shared/ApiKeyMissingModal.tsx
delete mode 100644 frontend/src/app/components/shared/ConfirmPopup.tsx
delete mode 100644 frontend/src/app/components/shared/DocPanel.tsx
delete mode 100644 frontend/src/app/components/shared/DocView.tsx
delete mode 100644 frontend/src/app/components/shared/DocViewModal.tsx
delete mode 100644 frontend/src/app/components/shared/DocumentCard.tsx
delete mode 100644 frontend/src/app/components/shared/DocxView.tsx
delete mode 100644 frontend/src/app/components/shared/EmailPillInput.tsx
create mode 100644 frontend/src/app/components/shared/FileTypeIcon.tsx
delete mode 100644 frontend/src/app/components/shared/MfaVerificationPopup.tsx
delete mode 100644 frontend/src/app/components/shared/Modal.tsx
delete mode 100644 frontend/src/app/components/shared/OwnerOnlyModal.tsx
delete mode 100644 frontend/src/app/components/shared/PeopleModal.tsx
delete mode 100644 frontend/src/app/components/shared/PreResponseWrapper.tsx
delete mode 100644 frontend/src/app/components/shared/ProjectPicker.tsx
delete mode 100644 frontend/src/app/components/shared/RelevantQuotes.tsx
delete mode 100644 frontend/src/app/components/shared/RenameableTitle.tsx
delete mode 100644 frontend/src/app/components/shared/UploadNewVersionModal.tsx
delete mode 100644 frontend/src/app/components/shared/WarningPopup.tsx
delete mode 100644 frontend/src/app/components/shared/highlightDocxQuote.ts
delete mode 100644 frontend/src/app/components/shared/highlightQuote.ts
delete mode 100644 frontend/src/app/components/tabular/AddNewTRModal.tsx
delete mode 100644 frontend/src/app/components/tabular/prompt-generator.ts
delete mode 100644 frontend/src/app/components/workflows/DisplayWorkflowModal.tsx
delete mode 100644 frontend/src/app/components/workflows/ShareWorkflowModal.tsx
delete mode 100644 frontend/src/app/components/workflows/WorkflowDetailsModal.tsx
delete mode 100644 frontend/src/app/components/workflows/builtinWorkflows.ts
delete mode 100644 frontend/src/app/hooks/useDocumentVersions.ts
delete mode 100644 frontend/src/components/ui/badge.tsx
create mode 100644 frontend/src/components/ui/pill-button.tsx
delete mode 100644 frontend/src/components/ui/text-search-widget.tsx
delete mode 100644 frontend/src/lib/auth.ts
delete mode 100644 frontend/src/lib/fileConverter.ts
delete mode 100644 frontend/src/lib/label.ts
delete mode 100644 frontend/src/lib/slug.ts
delete mode 100644 frontend/src/lib/types.ts
delete mode 100644 frontend/src/scripts/convert-courts-to-ts.js
create mode 100644 frontend/src/types/luckyexcel.d.ts
create mode 100644 scripts/build-workflows.js
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5fbd2ed..3ab6e1c 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -20,6 +20,19 @@ Thanks for helping improve Mike. Please keep contributions small, focused, and e
- why
- testing
+## System Workflows
+
+System workflows live in `workflows/`. Update `metadata.json` for structured
+metadata like author, language, version, practice area, or jurisdictions,
+update `SKILL.md` for workflow instructions, and use `table-config.json` for
+tabular review columns.
+
+After changing system workflows, regenerate the app files:
+
+```bash
+node scripts/build-workflows.js
+```
+
## Security
Do not open a public issue for security vulnerabilities. Use [GitHub's private vulnerability reporting](https://github.com/willchen96/mike/security/advisories/new) instead.
diff --git a/backend/bun.lock b/backend/bun.lock
index 90061e1..50c811c 100644
--- a/backend/bun.lock
+++ b/backend/bun.lock
@@ -9,6 +9,7 @@
"@aws-sdk/client-s3": "^3.787.0",
"@aws-sdk/s3-request-presigner": "^3.787.0",
"@google/genai": "^1.50.1",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@supabase/supabase-js": "^2.49.4",
"cors": "^2.8.5",
"docx": "^9.5.0",
@@ -24,6 +25,7 @@
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
+ "zod": "^3.25.76",
},
"devDependencies": {
"@types/cors": "^2.8.17",
@@ -179,6 +181,10 @@
"@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="],
+ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="],
"@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="],
@@ -379,6 +385,10 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+ "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
"argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
@@ -423,6 +433,8 @@
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -471,16 +483,22 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
+ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
+
+ "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
+
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
"express-rate-limit": ["express-rate-limit@8.5.1", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
- "fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
+ "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
+
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
"fast-xml-parser": ["fast-xml-parser@5.7.2", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w=="],
@@ -523,6 +541,8 @@
"helmet": ["helmet@8.1.0", "", {}, "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg=="],
+ "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
+
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
@@ -533,7 +553,7 @@
"iceberg-js": ["iceberg-js@0.8.1", "", {}, "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA=="],
- "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="],
@@ -543,12 +563,22 @@
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
+
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
+ "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
@@ -577,9 +607,9 @@
"mime": ["mime@1.6.0", "", { "bin": "cli.js" }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
- "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
- "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
@@ -605,6 +635,8 @@
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
"option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
@@ -619,12 +651,16 @@
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
"pdfjs-dist": ["pdfjs-dist@4.10.38", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.65" } }, "sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
+ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
@@ -637,7 +673,7 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
- "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
@@ -647,12 +683,16 @@
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
"resend": ["resend@4.8.0", "", { "dependencies": { "@react-email/render": "1.1.2" } }, "sha512-R8eBOFQDO6dzRTDmaMEdpqrkmgSjPpVXt4nGfWsZdYOet0kqra0xgbvTES6HmCriZEXbmGk3e0DiGIaLFTFSHA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
+ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
@@ -671,6 +711,10 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
@@ -719,6 +763,10 @@
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
"xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],
@@ -729,6 +777,10 @@
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
+ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
@@ -737,20 +789,36 @@
"@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
+ "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
+
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
+ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
+ "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
+
+ "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
+
"docx/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"protobufjs/@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="],
+ "react-promise-suspense/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="],
+
"readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+ "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
+
"send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
+ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
@@ -761,16 +829,58 @@
"@aws-sdk/xml-builder/fast-xml-parser/path-expression-matcher": ["path-expression-matcher@1.4.0", "", {}, "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q=="],
+ "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
+ "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="],
+
+ "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
+
+ "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+
+ "@modelcontextprotocol/sdk/express/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+
+ "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+
+ "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+
+ "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
+
+ "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+
+ "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
+
+ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"docx/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"https-proxy-agent/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"protobufjs/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+ "router/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+
+ "@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
+ "@modelcontextprotocol/sdk/express/body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+
+ "@modelcontextprotocol/sdk/express/body-parser/qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
+
+ "@modelcontextprotocol/sdk/express/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "@modelcontextprotocol/sdk/express/send/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
+
+ "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
}
}
diff --git a/backend/migrations/20260613_05_workflows_overview_rpc.sql b/backend/migrations/20260613_05_workflows_overview_rpc.sql
index d20a5bf..f7b7c77 100644
--- a/backend/migrations/20260613_05_workflows_overview_rpc.sql
+++ b/backend/migrations/20260613_05_workflows_overview_rpc.sql
@@ -27,19 +27,34 @@ stable
as $$
with owned as (
select
- w.*,
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.practice,
+ false as is_system,
+ w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
- where w.user_id = p_user_id
- and w.is_system = false
+ where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
- w.*,
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.practice,
+ false as is_system,
+ w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
@@ -48,7 +63,7 @@ as $$
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
- on up.user_id::text = ws.shared_by_user_id
+ on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
diff --git a/backend/migrations/20260625_01_workflow_metadata.sql b/backend/migrations/20260625_01_workflow_metadata.sql
new file mode 100644
index 0000000..3391c6b
--- /dev/null
+++ b/backend/migrations/20260625_01_workflow_metadata.sql
@@ -0,0 +1,120 @@
+-- Migration date: 2026-06-25
+
+-- Custom workflow metadata fields and workflow overview read model. System
+-- workflow versions remain generated from the repository metadata and are not
+-- stored on user workflow rows.
+
+drop function if exists public.get_workflows_overview(text, text, text);
+
+alter table public.workflows
+ drop column if exists author,
+ drop column if exists category,
+ drop column if exists is_system,
+ add column if not exists language text default 'English',
+ add column if not exists jurisdictions text[] default array['General']::text[];
+
+alter table public.workflows
+ alter column language set default 'English',
+ alter column practice set default 'General Transactions',
+ alter column jurisdictions set default array['General']::text[];
+
+update public.workflows
+set
+ language = coalesce(nullif(trim(language), ''), 'English'),
+ practice = coalesce(nullif(trim(practice), ''), 'General Transactions'),
+ jurisdictions = coalesce(jurisdictions, array['General']::text[])
+where user_id is not null;
+
+create or replace function public.get_workflows_overview(
+ p_user_id text,
+ p_user_email text default null,
+ p_type text default null
+)
+returns table (
+ id uuid,
+ user_id text,
+ title text,
+ type text,
+ prompt_md text,
+ columns_config jsonb,
+ language text,
+ practice text,
+ jurisdictions text[],
+ is_system boolean,
+ created_at timestamptz,
+ allow_edit boolean,
+ is_owner boolean,
+ shared_by_name text
+)
+language sql
+stable
+as $$
+ with owned as (
+ select
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.language,
+ w.practice,
+ w.jurisdictions,
+ false as is_system,
+ w.created_at,
+ true as allow_edit,
+ true as is_owner,
+ null::text as shared_by_name,
+ 0 as sort_bucket
+ from public.workflows w
+ where w.user_id::text = p_user_id
+ and (p_type is null or w.type = p_type)
+ ),
+ shared as (
+ select
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.language,
+ w.practice,
+ w.jurisdictions,
+ false as is_system,
+ w.created_at,
+ ws.allow_edit,
+ false as is_owner,
+ nullif(trim(up.display_name), '') as shared_by_name,
+ 1 as sort_bucket
+ from public.workflow_shares ws
+ join public.workflows w
+ on w.id = ws.workflow_id
+ left join public.user_profiles up
+ on up.user_id::text = ws.shared_by_user_id::text
+ where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
+ and (p_type is null or w.type = p_type)
+ ),
+ visible_workflows as (
+ select * from owned
+ union all
+ select * from shared
+ )
+ select
+ vw.id,
+ vw.user_id,
+ vw.title,
+ vw.type,
+ vw.prompt_md,
+ vw.columns_config,
+ vw.language,
+ vw.practice,
+ vw.jurisdictions,
+ vw.is_system,
+ vw.created_at,
+ vw.allow_edit,
+ vw.is_owner,
+ vw.shared_by_name
+ from visible_workflows vw
+ order by vw.sort_bucket asc, vw.created_at desc;
+$$;
diff --git a/backend/migrations/20260629_01_workflow_open_source_submissions.sql b/backend/migrations/20260629_01_workflow_open_source_submissions.sql
new file mode 100644
index 0000000..5cf1f99
--- /dev/null
+++ b/backend/migrations/20260629_01_workflow_open_source_submissions.sql
@@ -0,0 +1,39 @@
+-- Migration date: 2026-06-29
+
+-- Review queue for user-submitted workflows that may later be published to the
+-- open-source workflow repository. The backend writes with the service role.
+
+create table if not exists public.workflow_open_source_submissions (
+ id uuid primary key default gen_random_uuid(),
+ workflow_id uuid not null references public.workflows(id) on delete cascade,
+ submitted_by_user_id text not null,
+ submitter_email text,
+ submitter_name text,
+ contributor_mode text not null default 'anonymous',
+ status text not null default 'pending',
+ snapshot jsonb not null,
+ submitted_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ reviewed_at timestamptz,
+ reviewed_by_user_id text,
+ review_notes text,
+ constraint workflow_open_source_submissions_status_check
+ check (status in ('pending', 'approved', 'rejected')),
+ constraint workflow_open_source_submissions_contributor_mode_check
+ check (contributor_mode in ('named', 'anonymous'))
+);
+
+create unique index if not exists idx_workflow_open_source_submissions_pending
+ on public.workflow_open_source_submissions(workflow_id, submitted_by_user_id)
+ where status = 'pending';
+
+create index if not exists idx_workflow_open_source_submissions_reviewer_queue
+ on public.workflow_open_source_submissions(status, submitted_at desc);
+
+create index if not exists idx_workflow_open_source_submissions_submitter
+ on public.workflow_open_source_submissions(submitted_by_user_id, submitted_at desc);
+
+alter table public.workflow_open_source_submissions enable row level security;
+
+revoke all privileges on table public.workflow_open_source_submissions
+ from anon, authenticated;
diff --git a/backend/package-lock.json b/backend/package-lock.json
index ead4e3b..c7597a9 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -7,7 +7,6 @@
"": {
"name": "mike-backend",
"version": "1.0.0",
- "license": "AGPL-3.0-only",
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@aws-sdk/client-s3": "^3.787.0",
@@ -29,6 +28,7 @@
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -5473,6 +5473,18 @@
}
}
},
+ "node_modules/xlsx": {
+ "version": "0.20.3",
+ "resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
+ "integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
+ "license": "Apache-2.0",
+ "bin": {
+ "xlsx": "bin/xlsx.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index fb7a18b..5ef2f07 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
+ "prebuild": "node ../scripts/build-workflows.js",
"build": "tsc",
"start": "node dist/index.js"
},
@@ -28,6 +29,7 @@
"multer": "^1.4.5-lts.2",
"pdfjs-dist": "^4.10.38",
"resend": "^4.5.1",
+ "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
},
"devDependencies": {
diff --git a/backend/schema.sql b/backend/schema.sql
index 9dec314..5e48fa5 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -12,6 +12,7 @@ create extension if not exists "pgcrypto";
create table if not exists public.user_profiles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null unique references auth.users(id) on delete cascade,
+ email text,
display_name text,
organisation text,
tier text not null default 'Free',
@@ -29,6 +30,13 @@ create table if not exists public.user_profiles (
create index if not exists idx_user_profiles_user
on public.user_profiles(user_id);
+create unique index if not exists user_profiles_email_lower_unique
+ on public.user_profiles (lower(email))
+ where email is not null and btrim(email) <> '';
+
+create index if not exists idx_user_profiles_email
+ on public.user_profiles(email);
+
create or replace function public.handle_new_user()
returns trigger
language plpgsql
@@ -36,9 +44,11 @@ security definer
set search_path = public
as $$
begin
- insert into public.user_profiles (user_id)
- values (new.id)
- on conflict (user_id) do nothing;
+ insert into public.user_profiles (user_id, email)
+ values (new.id, lower(new.email))
+ on conflict (user_id) do update
+ set email = excluded.email,
+ updated_at = now();
return new;
exception when others then
-- Never block signup if the profile insert fails.
@@ -186,6 +196,7 @@ create table if not exists public.projects (
user_id text not null,
name text not null,
cm_number text,
+ practice text,
visibility text not null default 'private',
shared_with jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now(),
@@ -323,8 +334,9 @@ create table if not exists public.workflows (
type text not null,
prompt_md text,
columns_config jsonb,
- practice text,
- is_system boolean not null default false,
+ language text default 'English',
+ practice text default 'General Transactions',
+ jurisdictions text[] default array['General']::text[],
created_at timestamptz not null default now()
);
@@ -371,7 +383,9 @@ returns table (
type text,
prompt_md text,
columns_config jsonb,
+ language text,
practice text,
+ jurisdictions text[],
is_system boolean,
created_at timestamptz,
allow_edit boolean,
@@ -383,19 +397,38 @@ stable
as $$
with owned as (
select
- w.*,
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.language,
+ w.practice,
+ w.jurisdictions,
+ false as is_system,
+ w.created_at,
true as allow_edit,
true as is_owner,
null::text as shared_by_name,
0 as sort_bucket
from public.workflows w
- where w.user_id = p_user_id
- and w.is_system = false
+ where w.user_id::text = p_user_id
and (p_type is null or w.type = p_type)
),
shared as (
select
- w.*,
+ w.id,
+ w.user_id::text as user_id,
+ w.title,
+ w.type,
+ w.prompt_md,
+ w.columns_config,
+ w.language,
+ w.practice,
+ w.jurisdictions,
+ false as is_system,
+ w.created_at,
ws.allow_edit,
false as is_owner,
nullif(trim(up.display_name), '') as shared_by_name,
@@ -404,7 +437,7 @@ as $$
join public.workflows w
on w.id = ws.workflow_id
left join public.user_profiles up
- on up.user_id::text = ws.shared_by_user_id
+ on up.user_id::text = ws.shared_by_user_id::text
where lower(ws.shared_with_email) = lower(coalesce(p_user_email, ''))
and (p_type is null or w.type = p_type)
),
@@ -420,7 +453,9 @@ as $$
vw.type,
vw.prompt_md,
vw.columns_config,
+ vw.language,
vw.practice,
+ vw.jurisdictions,
vw.is_system,
vw.created_at,
vw.allow_edit,
@@ -489,7 +524,7 @@ create table if not exists public.chat_messages (
role text not null,
content jsonb,
files jsonb,
- annotations jsonb,
+ citations jsonb,
created_at timestamptz not null default now()
);
@@ -549,6 +584,7 @@ returns table (
user_id text,
name text,
cm_number text,
+ practice text,
shared_with jsonb,
created_at timestamptz,
updated_at timestamptz,
@@ -595,6 +631,7 @@ as $$
vp.user_id,
vp.name,
vp.cm_number,
+ vp.practice,
vp.shared_with,
vp.created_at,
vp.updated_at,
diff --git a/backend/src/lib/builtinWorkflows.ts b/backend/src/lib/builtinWorkflows.ts
deleted file mode 100644
index 7154d77..0000000
--- a/backend/src/lib/builtinWorkflows.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-export const BUILTIN_WORKFLOWS: { id: string; title: string; prompt_md: string }[] = [
- {
- id: "builtin-cp-checklist",
- title: "Generate CP Checklist",
- prompt_md:
- "## Generate Conditions Precedent Checklist\n\n" +
- "Review the uploaded credit agreement or financing document and generate a comprehensive " +
- "Conditions Precedent (CP) checklist.\n\n" +
- "You MUST use the generate_docx tool to produce the checklist as a downloadable Word document. " +
- "You MUST pass landscape: true to the generate_docx tool — the document must be in landscape orientation. " +
- "Do not display the checklist inline — generate the .docx file and provide the download link.\n\n" +
- "Structure the document as follows:\n" +
- "- For each category of conditions (e.g. Corporate, Financial, Legal, Security), add a section with a heading\n" +
- "- Under each category heading, include a table with exactly these four columns in this order:\n" +
- " 1. Index — sequential number within the category (1, 2, 3…)\n" +
- " 2. Clause Number — the clause or schedule reference from the agreement\n" +
- " 3. Clause — a concise description of the condition precedent\n" +
- " 4. Status — leave blank (empty string) for the user to fill in\n\n" +
- "Use the table field in the section object (not content) for each category's rows.\n\n" +
- "Before finalizing, double-check that every table is formatted correctly: each table must have exactly the four columns above in the same order, headers must match exactly (Index, Clause Number, Clause, Status), every row must have the same number of cells as the headers, the Index column must be sequential starting from 1 within each category, and no cells should contain stray markdown, newlines, or placeholder text (use an empty string for Status).",
- },
- {
- id: "builtin-credit-summary",
- title: "Credit Agreement Summary",
- prompt_md:
- "## Credit Agreement Summary\n\n" +
- "Review the uploaded credit agreement and produce a comprehensive legal summary covering the following topics. " +
- "For each section, identify the key provisions, quote the relevant clause or schedule references, and flag any unusual, onerous, or non-market terms.\n\n" +
- "1. **Lenders** — All lenders or members of the lender syndicate, including their full legal name and role (e.g. mandated lead arranger, original lender, agent bank)\n" +
- "2. **Borrowers** — All borrowers, including their full legal name and jurisdiction of incorporation\n" +
- "3. **Guarantors** — All guarantors, including their full legal name and the scope of their guarantee obligation\n" +
- "4. **Other Parties** — Any other material parties (e.g. facility agent, security agent, hedge counterparties, issuing bank) and their roles\n" +
- "5. **Date of Agreement** — Date of the credit agreement\n" +
- "6. **Facilities** — Each facility available (e.g. Revolving Credit Facility, Term Loan A, Term Loan B, Term Loan C), the facility type, tranche name, and any key structural features\n" +
- "7. **Amount** — Total committed amount across all facilities, the currency, and breakdown by tranche if applicable\n" +
- "8. **Purpose** — Stated purpose for which borrowings may be used and any restrictions on use of proceeds\n" +
- "9. **Interest** — Applicable reference rate (e.g. SOFR, EURIBOR, base rate), the margin, any margin ratchet mechanism, and how interest periods are structured\n" +
- "10. **Commitment Fee** — Commitment or utilisation fees, the applicable rate, how they are calculated, and the basis (e.g. undrawn commitment, average utilisation)\n" +
- "11. **Repayment Schedule** — Repayment profile for each facility, whether by scheduled instalments or bullet repayment, and the repayment dates and amounts\n" +
- "12. **Maturity** — Final maturity date for each facility\n" +
- "13. **Security** — Each class of security granted or required (e.g. share pledges, fixed and floating charges, real estate mortgages, account pledges) and the assets or entities over which security is taken\n" +
- "14. **Guarantees** — Guarantee obligations, the guarantors, the scope of the guarantee, and any limitations (e.g. up-stream guarantee limitations, guarantor coverage test)\n" +
- "15. **Financial Covenants** — Each financial covenant, the metric (e.g. leverage ratio, interest cover, cashflow cover), the applicable test, testing frequency, and any equity cure rights\n" +
- "16. **Events of Default** — Each event of default, noting any grace periods, materiality thresholds, or cross-default provisions\n" +
- "17. **Assignment** — Restrictions or permissions on assignment or transfer (e.g. white/blacklists, borrower consent for lender transfers; restrictions on borrower assignment)\n" +
- "18. **Change of Control** — What constitutes a change of control, what obligations it triggers (e.g. mandatory prepayment, cancellation, lender consent), and any cure period\n" +
- "19. **Prepayment Fee** — Any prepayment fees, make-whole premiums, or soft-call protections, the applicable fee, the period during which it applies, and any exceptions (e.g. prepayment from insurance proceeds or asset disposals)\n" +
- "20. **Governing Law** — Governing law of the agreement\n" +
- "21. **Dispute Resolution** — Whether disputes go to litigation or arbitration, the chosen forum or seat, and any submission to jurisdiction provisions\n\n" +
- "Deliver the summary inline in your chat response — do NOT call generate_docx. Only produce a downloadable Word document if the user explicitly asks for one.",
- },
- {
- id: "builtin-sha-summary",
- title: "Shareholder Agreement Summary",
- prompt_md:
- "## Shareholder Agreement Summary\n\n" +
- "Review the uploaded shareholder agreement and produce a comprehensive legal summary covering the following topics. " +
- "For each section, identify the key provisions, quote the relevant clause references, and flag any unusual, onerous, or market-standard deviations.\n\n" +
- "1. **Parties & Shareholdings** — Full legal names, roles, share classes held, and percentage interests (on a fully diluted basis if stated)\n" +
- "2. **Share Classes & Rights** — For each class: voting rights, dividend rights, liquidation preference, conversion or redemption features\n" +
- "3. **Board Composition & Governance** — Board size, director appointment rights (and the shareholding thresholds required to maintain them), quorum, and casting vote\n" +
- "4. **Reserved Matters** — Decisions requiring a special majority, unanimity, or a specific shareholder's consent; note the threshold and whose consent is required for each\n" +
- "5. **Pre-emption on New Shares** — Who holds pre-emption rights, procedure, timeline, and any carve-outs (e.g. employee option schemes)\n" +
- "6. **Transfer Restrictions** — Lock-up periods, prohibited transfers, permitted transfers (e.g. to affiliates), and any board or shareholder approval requirements\n" +
- "7. **Right of First Refusal / Pre-emption on Transfer** — Trigger, procedure, pricing mechanics, and any exceptions\n" +
- "8. **Drag-Along Rights** — Who holds the right, threshold to trigger, conditions (e.g. minimum price, independent valuation), and minority protections\n" +
- "9. **Tag-Along Rights** — Who holds the right, triggering threshold, exercise procedure, and price terms\n" +
- "10. **Anti-Dilution Protections** — Type (full ratchet, weighted average), trigger events, calculation mechanics, and exceptions\n" +
- "11. **Dividend Policy** — Any obligation or target to pay dividends, preferential dividend rights, and restrictions on distributions\n" +
- "12. **Exit & Liquidity** — Agreed exit routes (trade sale, IPO, drag sale), timelines, and liquidation preferences on exit\n" +
- "13. **Deadlock** — Deadlock definition, escalation and resolution mechanisms (e.g. Russian roulette, put/call options), and consequences if unresolved\n" +
- "14. **Non-Compete & Non-Solicitation** — Who is bound, scope of activities and geography, duration, and carve-outs\n" +
- "15. **Governing Law & Dispute Resolution** — Applicable law, forum, arbitration or litigation, and any mandatory escalation steps\n\n" +
- "Generate the summary as a downloadable Word document.",
- },
-];
diff --git a/backend/src/lib/chat/citations.ts b/backend/src/lib/chat/citations.ts
new file mode 100644
index 0000000..cd7fb0a
--- /dev/null
+++ b/backend/src/lib/chat/citations.ts
@@ -0,0 +1,310 @@
+import { type DocIndex, resolveDoc } from "./types";
+
+// ---------------------------------------------------------------------------
+// Internal citation parse types
+// ---------------------------------------------------------------------------
+
+type DocumentQuote = {
+ page: number | string;
+ quote: string;
+ // Spreadsheet sources are located by cell instead of page: `sheet` is the
+ // worksheet name and `cell` is an A1 address or range (e.g. "B7" or "B7:C9").
+ sheet?: string;
+ cell?: string;
+};
+
+type ParsedDocumentCitation = {
+ kind: "document";
+ ref: number;
+ doc_id: string;
+ page: number | string;
+ quote: string;
+ sheet?: string;
+ cell?: string;
+ quotes: DocumentQuote[];
+};
+
+type ParsedCaseCitation = {
+ kind: "case";
+ ref: number;
+ cluster_id: number;
+ quotes: {
+ opinionId: number | null;
+ type: string | null;
+ author: string | null;
+ quote: string;
+ }[];
+};
+
+type ParsedCitation = ParsedDocumentCitation | ParsedCaseCitation;
+
+function normalizeCitation(raw: unknown): ParsedCitation | null {
+ if (!raw || typeof raw !== "object") return null;
+ const c = raw as Record;
+ const markerRef =
+ typeof c.marker === "string"
+ ? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
+ : NaN;
+ const ref =
+ typeof c.ref === "number"
+ ? c.ref
+ : Number.isFinite(markerRef)
+ ? markerRef
+ : null;
+ if (typeof ref !== "number") return null;
+ const quote = typeof c.quote === "string" ? c.quote : c.text;
+
+ const rawClusterId =
+ typeof c.cluster_id === "number"
+ ? c.cluster_id
+ : typeof c.clusterId === "number"
+ ? c.clusterId
+ : typeof c.cluster_id === "string"
+ ? Number.parseInt(c.cluster_id, 10)
+ : typeof c.clusterId === "string"
+ ? Number.parseInt(c.clusterId, 10)
+ : NaN;
+ if (Number.isFinite(rawClusterId) && rawClusterId > 0) {
+ const quotes = normalizeCaseCitationQuotes(c);
+ if (!quotes.length) {
+ if (typeof quote !== "string" || !quote) return null;
+ quotes.push({ opinionId: null, type: null, author: null, quote });
+ }
+ return { kind: "case", ref, cluster_id: Math.floor(rawClusterId), quotes };
+ }
+
+ if (typeof c.doc_id !== "string") return null;
+ const quotes = normalizeDocumentCitationQuotes(c);
+ if (!quotes.length) {
+ if (typeof quote !== "string" || !quote) return null;
+ quotes.push({
+ page: normalizeCitationPage(c.page),
+ quote,
+ ...normalizeCellLocator(c),
+ });
+ }
+ return {
+ kind: "document",
+ ref,
+ doc_id: c.doc_id,
+ page: quotes[0].page,
+ quote: quotes[0].quote,
+ sheet: quotes[0].sheet,
+ cell: quotes[0].cell,
+ quotes,
+ };
+}
+
+/** Pull an optional spreadsheet `{sheet, cell}` locator off a raw object. */
+function normalizeCellLocator(
+ c: Record,
+): { sheet?: string; cell?: string } {
+ const out: { sheet?: string; cell?: string } = {};
+ if (typeof c.sheet === "string" && c.sheet.trim()) out.sheet = c.sheet.trim();
+ if (typeof c.cell === "string" && c.cell.trim()) out.cell = c.cell.trim();
+ return out;
+}
+
+function normalizeCitationPage(value: unknown): number | string {
+ if (typeof value === "number") {
+ return value;
+ } else if (typeof value === "string" && /^\d+\s*-\s*\d+$/.test(value)) {
+ return value;
+ } else {
+ const n = parseInt(String(value ?? ""), 10);
+ if (!Number.isFinite(n)) return 1;
+ return n;
+ }
+}
+
+function normalizeDocumentCitationQuotes(
+ c: Record,
+): DocumentQuote[] {
+ if (!Array.isArray(c.quotes)) return [];
+ return c.quotes
+ .slice(0, 3)
+ .map((raw): DocumentQuote | null => {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const row = raw as Record;
+ const text = typeof row.quote === "string" ? row.quote : row.text;
+ if (typeof text !== "string" || !text.trim()) return null;
+ // Fall back to the top-level sheet/cell so a citation can set them once.
+ return {
+ page: normalizeCitationPage(row.page ?? c.page),
+ quote: text,
+ ...normalizeCellLocator({
+ sheet: row.sheet ?? c.sheet,
+ cell: row.cell ?? c.cell,
+ }),
+ };
+ })
+ .filter((quote): quote is DocumentQuote => !!quote);
+}
+
+function normalizeCaseCitationQuotes(c: Record) {
+ if (!Array.isArray(c.quotes)) return [];
+ return c.quotes
+ .slice(0, 3)
+ .map((raw) => {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
+ const row = raw as Record;
+ const text = typeof row.quote === "string" ? row.quote : row.text;
+ if (typeof text !== "string" || !text.trim()) return null;
+ const opinionId =
+ typeof row.opinion_id === "number" && Number.isFinite(row.opinion_id)
+ ? Math.floor(row.opinion_id)
+ : typeof row.opinionId === "number" && Number.isFinite(row.opinionId)
+ ? Math.floor(row.opinionId)
+ : null;
+ return {
+ opinionId,
+ type: typeof row.type === "string" ? row.type : null,
+ author: typeof row.author === "string" ? row.author : null,
+ quote: text,
+ };
+ })
+ .filter(
+ (quote): quote is {
+ opinionId: number | null;
+ type: string | null;
+ author: string | null;
+ quote: string;
+ } => !!quote,
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Citation block constants and parsers
+// ---------------------------------------------------------------------------
+
+export const CITATIONS_BLOCK_RE = /\s*([\s\S]*?)\s*<\/CITATIONS>/;
+export const CITATIONS_OPEN_TAG = "";
+export const CITATIONS_CLOSE_TAG = "";
+
+type CitationParseDiagnostics = {
+ hasBlock: boolean;
+ rawLength: number;
+ error: string | null;
+};
+
+export function parseCitationsWithDiagnostics(text: string): {
+ citations: ParsedCitation[];
+ diagnostics: CitationParseDiagnostics;
+} {
+ const match = text.match(CITATIONS_BLOCK_RE);
+ if (!match) {
+ return { citations: [], diagnostics: { hasBlock: false, rawLength: 0, error: null } };
+ }
+ const raw = match[1] ?? "";
+ try {
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return {
+ citations: [],
+ diagnostics: { hasBlock: true, rawLength: raw.length, error: "CITATIONS block JSON was not an array." },
+ };
+ }
+ return {
+ citations: parsed.map(normalizeCitation).filter((c): c is ParsedCitation => c !== null),
+ diagnostics: { hasBlock: true, rawLength: raw.length, error: null },
+ };
+ } catch (error) {
+ return {
+ citations: [],
+ diagnostics: {
+ hasBlock: true,
+ rawLength: raw.length,
+ error: error instanceof Error ? error.message : String(error),
+ },
+ };
+ }
+}
+
+export function parseCitations(text: string): ParsedCitation[] {
+ return parseCitationsWithDiagnostics(text).citations;
+}
+
+export function parsePartialCitationObjects(text: string): ParsedCitation[] {
+ const beforeClose = text.split(CITATIONS_CLOSE_TAG)[0] ?? text;
+ const arrayStart = beforeClose.indexOf("[");
+ if (arrayStart < 0) return [];
+
+ const parsed: ParsedCitation[] = [];
+ let inString = false;
+ let escaped = false;
+ let depth = 0;
+ let objectStart = -1;
+
+ for (let i = arrayStart + 1; i < beforeClose.length; i += 1) {
+ const char = beforeClose[i];
+ if (escaped) { escaped = false; continue; }
+ if (char === "\\") { escaped = inString; continue; }
+ if (char === '"') { inString = !inString; continue; }
+ if (inString) continue;
+ if (char === "{") {
+ if (depth === 0) objectStart = i;
+ depth += 1;
+ } else if (char === "}") {
+ if (depth === 0) continue;
+ depth -= 1;
+ if (depth === 0 && objectStart >= 0) {
+ try {
+ const raw = JSON.parse(beforeClose.slice(objectStart, i + 1));
+ const citation = normalizeCitation(raw);
+ if (citation) parsed.push(citation);
+ } catch { /* ignore incomplete/malformed partial object */ }
+ objectStart = -1;
+ }
+ } else if (char === "]" && depth === 0) {
+ break;
+ }
+ }
+ return parsed;
+}
+
+type CasesByClusterId = Map;
+
+export function createCitation(
+ citation: ParsedCitation,
+ docIndex: DocIndex,
+ casesByClusterId?: CasesByClusterId,
+) {
+ if (citation.kind === "case") {
+ const caseRecord = casesByClusterId?.get(citation.cluster_id);
+ return {
+ type: "citation_data",
+ kind: "case",
+ ref: citation.ref,
+ cluster_id: citation.cluster_id,
+ case_name: caseRecord?.caseName ?? null,
+ citation: caseRecord?.citations[0] ?? null,
+ url: caseRecord?.url ?? null,
+ pdfUrl: caseRecord?.pdfUrl ?? null,
+ dateFiled: caseRecord?.dateFiled ?? null,
+ quotes: citation.quotes,
+ };
+ }
+
+ const docInfo = resolveDoc(citation.doc_id, docIndex);
+ return {
+ type: "citation_data",
+ kind: "document",
+ ref: citation.ref,
+ doc_id: citation.doc_id,
+ document_id: docInfo?.document_id,
+ version_id: docInfo?.version_id ?? null,
+ version_number: docInfo?.version_number ?? null,
+ filename: docInfo?.filename ?? citation.doc_id,
+ page: citation.page,
+ quote: citation.quote,
+ sheet: citation.sheet,
+ cell: citation.cell,
+ quotes: citation.quotes,
+ };
+}
diff --git a/backend/src/lib/chat/contextBuilders.ts b/backend/src/lib/chat/contextBuilders.ts
new file mode 100644
index 0000000..c1794fa
--- /dev/null
+++ b/backend/src/lib/chat/contextBuilders.ts
@@ -0,0 +1,583 @@
+import { createServerSupabase } from "../supabase";
+import {
+ attachActiveVersionPaths,
+} from "../documentVersions";
+import {
+ type DocStore,
+ type DocIndex,
+ type WorkflowStore,
+ type ChatMessage,
+ type AskInputsResponseRequest,
+ type AskInputResponseItem,
+ devLog,
+} from "./types";
+import { buildSystemPrompt } from "./prompts";
+import { parseCitations, createCitation } from "./citations";
+import type { AssistantEvent } from "./streaming";
+
+
+export async function enrichWithPriorEvents(
+ messages: ChatMessage[],
+ chatId: string | null | undefined,
+ db: ReturnType,
+ docIndex: DocIndex,
+): Promise {
+ if (!chatId) return messages;
+ const { data: rows } = await db
+ .from("chat_messages")
+ .select("content, created_at")
+ .eq("chat_id", chatId)
+ .eq("role", "assistant")
+ .order("created_at", { ascending: false })
+ .limit(1);
+
+ const lastRow = rows?.[0] as { content?: unknown } | undefined;
+ const content = lastRow?.content;
+ if (!Array.isArray(content)) return messages;
+
+ const slugByDocumentId = new Map();
+ for (const [slug, info] of Object.entries(docIndex)) {
+ if (info.document_id) slugByDocumentId.set(info.document_id, slug);
+ }
+ const refFor = (documentId: unknown, filename: unknown) => {
+ const slug =
+ typeof documentId === "string"
+ ? slugByDocumentId.get(documentId)
+ : undefined;
+ return slug ? `${slug} ("${filename}")` : `"${filename}"`;
+ };
+
+ const lines: string[] = [];
+ for (const ev of content as Record[]) {
+ if (ev?.type === "doc_created") {
+ lines.push(`- generated_document → ${refFor(ev.document_id, ev.filename)}`);
+ } else if (ev?.type === "doc_edited") {
+ lines.push(`- edit_document → ${refFor(ev.document_id, ev.filename)}`);
+ } else if (ev?.type === "doc_read") {
+ lines.push(`- read_document → ${refFor(ev.document_id, ev.filename)}`);
+ } else if (ev?.type === "doc_replicated") {
+ // The model needs to know what each copy resolved to so it
+ // can call edit_document / read_document on them. Emit one
+ // line per copy, all attributed back to the same source.
+ const srcLabel =
+ typeof ev.filename === "string" ? `"${ev.filename}"` : "";
+ const copies = Array.isArray(ev.copies)
+ ? (ev.copies as {
+ new_filename?: unknown;
+ document_id?: unknown;
+ }[])
+ : [];
+ for (const c of copies) {
+ const ref = refFor(c.document_id, c.new_filename);
+ lines.push(
+ srcLabel
+ ? `- replicate_document → ${ref} (copy of ${srcLabel})`
+ : `- replicate_document → ${ref}`,
+ );
+ }
+ } else if (ev?.type === "workflow_applied") {
+ lines.push(`- applied workflow: "${ev.title}"`);
+ } else if (ev?.type === "ask_inputs") {
+ const count = Array.isArray(ev.items) ? ev.items.length : 0;
+ lines.push(`- asked user for ${count} input${count === 1 ? "" : "s"}`);
+ } else if (ev?.type === "ask_inputs_response") {
+ const responses = Array.isArray(ev.responses) ? ev.responses : [];
+ for (const response of responses) {
+ if (!response || typeof response !== "object") continue;
+ const row = response as Record;
+ if (row.skipped) {
+ lines.push("- user skipped an input");
+ } else if (row.kind === "choice" && typeof row.answer === "string") {
+ lines.push(`- user answered: "${row.answer}"`);
+ } else if (
+ row.kind === "documents" &&
+ Array.isArray(row.filenames)
+ ) {
+ lines.push(
+ `- user attached documents: ${row.filenames.join(", ") || "none"}`,
+ );
+ }
+ }
+ }
+ }
+ if (lines.length === 0) return messages;
+ const summary = `\n\n[Tool activity in your previous turn]\n${lines.join("\n")}`;
+
+ // Find the index of the last assistant message and attach the
+ // summary there only.
+ let lastAssistantIdx = -1;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].role === "assistant") {
+ lastAssistantIdx = i;
+ break;
+ }
+ }
+ if (lastAssistantIdx < 0) return messages;
+ const enriched = messages.slice();
+ const target = enriched[lastAssistantIdx];
+ enriched[lastAssistantIdx] = {
+ ...target,
+ content: (target.content ?? "") + summary,
+ };
+ return enriched;
+}
+
+export function buildMessages(
+ messages: ChatMessage[],
+ docAvailability: {
+ doc_id: string;
+ filename: string;
+ folder_path?: string;
+ }[],
+ systemPromptExtra?: string,
+ docIndex?: DocIndex,
+ includeResearchTools = true,
+) {
+ const formatted: unknown[] = [];
+ let systemContent = buildSystemPrompt(includeResearchTools);
+
+ if (systemPromptExtra) {
+ systemContent += `\n\n${systemPromptExtra.trim()}`;
+ }
+
+ if (docAvailability.length) {
+ systemContent += "\n\n---\nAVAILABLE DOCUMENTS:\n";
+ for (const doc of docAvailability) {
+ const label = doc.folder_path
+ ? `${doc.folder_path} / ${doc.filename}`
+ : doc.filename;
+ systemContent += `- ${doc.doc_id}: ${label}\n`;
+ }
+ systemContent +=
+ "\nYou do NOT retain document content between conversation turns. You MUST call read_document (or fetch_documents) once at the start of every response that involves a document's content, even if you have read it in a previous turn. Within the same response, do not call read_document or fetch_documents again for a document/version that has already been read; use the prior tool result, find_in_document for targeted checks, or proceed to the next required tool. Failure to read once per turn will result in hallucinated or stale content.\n---\n";
+ }
+ formatted.push({ role: "system", content: systemContent });
+
+ // Map document_id (UUID) → current-turn doc_id slug, so when we
+ // inline a user attachment we hand the model the same handle it
+ // would use to call read_document / fetch_documents.
+ const slugByDocumentId = new Map();
+ if (docIndex) {
+ for (const [slug, info] of Object.entries(docIndex)) {
+ if (info.document_id) slugByDocumentId.set(info.document_id, slug);
+ }
+ }
+
+ for (const msg of messages) {
+ let content = msg.content ?? "";
+ if (msg.role === "user" && msg.workflow) {
+ content = `[Workflow: ${msg.workflow.title} (id: ${msg.workflow.id})]\n\n${content}`;
+ }
+ if (msg.role === "user" && msg.files?.length) {
+ const lines = msg.files.map((f) => {
+ const slug = f.document_id
+ ? slugByDocumentId.get(f.document_id)
+ : undefined;
+ return slug ? `- ${slug}: ${f.filename}` : `- ${f.filename}`;
+ });
+ content = `[The user attached the following document(s) to this message:\n${lines.join("\n")}]\n\n${content}`;
+ }
+ formatted.push({ role: msg.role, content });
+ }
+ return formatted;
+}
+
+export function extractCitations(
+ fullText: string,
+ docIndex: DocIndex,
+ _events?: ({ type: string } & Record[]) | unknown[],
+): unknown[] {
+ return parseCitations(fullText).map((c) =>
+ createCitation(c, docIndex),
+ );
+}
+
+export function stripTransientAssistantEvents(events: AssistantEvent[]) {
+ return events.filter((event) => event.type !== "case_opinions");
+}
+
+function cleanAskInputResponseId(value: unknown) {
+ const id = typeof value === "string" ? value.trim() : "";
+ return id.slice(0, 80);
+}
+
+export function parseAskInputsResponsePayload(
+ value: unknown,
+): AskInputsResponseRequest | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const row = value as Record;
+ const rawResponses = Array.isArray(row.responses) ? row.responses : [];
+ const responses = rawResponses
+ .map((item): AskInputResponseItem | null => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
+ const current = item as Record;
+ const id = cleanAskInputResponseId(current.id);
+ const kind = current.kind;
+ const skipped = current.skipped === true;
+ if (!id || (kind !== "choice" && kind !== "documents")) return null;
+ if (kind === "choice") {
+ const question =
+ typeof current.question === "string"
+ ? current.question.trim().slice(0, 500)
+ : "";
+ const answer =
+ typeof current.answer === "string"
+ ? current.answer.trim().slice(0, 1000)
+ : "";
+ if (!question || (!answer && !skipped)) return null;
+ return {
+ id,
+ kind,
+ question,
+ ...(answer ? { answer } : {}),
+ ...(skipped ? { skipped: true } : {}),
+ };
+ }
+ const rawFilenames = Array.isArray(current.filenames)
+ ? current.filenames
+ : [];
+ const filenames = rawFilenames
+ .filter((f): f is string => typeof f === "string")
+ .map((f) => f.trim())
+ .filter(Boolean)
+ .slice(0, 50);
+ return {
+ id,
+ kind,
+ filenames,
+ ...(skipped ? { skipped: true } : {}),
+ };
+ })
+ .filter((item): item is AskInputResponseItem => !!item)
+ .slice(0, 20);
+ return responses.length > 0 ? { responses } : null;
+}
+
+export async function appendAskInputsResponseToLastAssistantMessage(
+ db: ReturnType,
+ chatId: string,
+ response: AskInputsResponseRequest,
+) {
+ await appendAssistantEventsToLastAssistantMessage(db, chatId, [
+ {
+ type: "ask_inputs_response" as const,
+ responses: response.responses,
+ },
+ ]);
+}
+
+export async function appendAssistantEventsToLastAssistantMessage(
+ db: ReturnType,
+ chatId: string,
+ events: AssistantEvent[],
+ citations?: unknown[],
+) {
+ if (events.length === 0 && (!citations || citations.length === 0)) {
+ return;
+ }
+ const { data: rows, error: selectError } = await db
+ .from("chat_messages")
+ .select("id, content, citations")
+ .eq("chat_id", chatId)
+ .eq("role", "assistant")
+ .order("created_at", { ascending: false })
+ .limit(1);
+ if (selectError || !rows?.[0]) {
+ if (selectError) {
+ console.error(
+ "[assistant-events] failed to load assistant message",
+ selectError,
+ );
+ }
+ return;
+ }
+
+ const row = rows[0] as {
+ id: string;
+ content: unknown;
+ citations?: unknown;
+ };
+ const existing = Array.isArray(row.content)
+ ? row.content
+ : [];
+ const next = [...existing, ...events];
+ const existingCitations = Array.isArray(row.citations)
+ ? row.citations
+ : [];
+ const nextCitations =
+ citations && citations.length > 0
+ ? [...existingCitations, ...citations]
+ : existingCitations;
+ const { error: updateError } = await db
+ .from("chat_messages")
+ .update({
+ content: next.length ? next : null,
+ citations: nextCitations.length ? nextCitations : null,
+ })
+ .eq("id", row.id);
+ if (updateError) {
+ console.error(
+ "[assistant-events] failed to update assistant message",
+ updateError,
+ );
+ }
+}
+
+export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
+ return [...events, { type: "content" as const, text: "Cancelled by user." }];
+}
+
+export function buildCancelledAssistantMessage(args: {
+ fullText: string;
+ events: AssistantEvent[];
+ buildCitations: (fullText: string, events: AssistantEvent[]) => unknown[];
+}) {
+ const events = appendCancelledAssistantEvent(
+ stripTransientAssistantEvents(args.events),
+ );
+ return {
+ events,
+ citations: args.buildCitations(args.fullText, events),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Document context builder (from message file attachments)
+// ---------------------------------------------------------------------------
+
+export async function buildDocContext(
+ messages: ChatMessage[],
+ userId: string,
+ db: ReturnType,
+ chatId?: string | null,
+): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
+ const docIndex: DocIndex = {};
+ const docStore: DocStore = new Map();
+
+ const documentIds = new Set();
+ for (const m of messages) {
+ for (const f of m.files ?? []) {
+ if (f.document_id) documentIds.add(f.document_id);
+ }
+ }
+
+ // Also pull in document_ids from prior assistant events in this chat —
+ // generated docs (generate_docx) and tracked-change edits (edit_document)
+ // aren't attached to user messages as files, so they only live in the
+ // assistant's `doc_created` / `doc_edited` events. Without this sweep
+ // the model loses access to generated docs after the turn that created
+ // them, and can't call edit_document / read_document on them.
+ if (chatId) {
+ const { data: rows } = await db
+ .from("chat_messages")
+ .select("content")
+ .eq("chat_id", chatId)
+ .eq("role", "assistant");
+ for (const row of rows ?? []) {
+ const content = (row as { content?: unknown }).content;
+ if (!Array.isArray(content)) continue;
+ for (const ev of content as Record[]) {
+ if (
+ (ev?.type === "doc_created" || ev?.type === "doc_edited") &&
+ typeof ev.document_id === "string"
+ ) {
+ documentIds.add(ev.document_id);
+ }
+ }
+ }
+ }
+
+ const ids = [...documentIds];
+ if (ids.length > 0) {
+ const { data: docs } = await db
+ .from("documents")
+ .select("id, current_version_id, status")
+ .in("id", ids)
+ .eq("user_id", userId)
+ .eq("status", "ready");
+
+ const docList = (docs ?? []) as unknown as {
+ id: string;
+ filename?: string | null;
+ file_type?: string | null;
+ current_version_id?: string | null;
+ active_version_number?: number | null;
+ storage_path?: string | null;
+ }[];
+ await attachActiveVersionPaths(db, docList);
+ for (let i = 0; i < docList.length; i++) {
+ const doc = docList[i];
+ if (!doc.storage_path) continue;
+ const docLabel = `doc-${i}`;
+ const filename = doc.filename?.trim() || "Untitled document";
+ docIndex[docLabel] = {
+ document_id: doc.id,
+ filename,
+ version_id: doc.current_version_id ?? null,
+ version_number: doc.active_version_number ?? null,
+ };
+ docStore.set(docLabel, {
+ storage_path: doc.storage_path,
+ file_type: doc.file_type ?? "",
+ filename,
+ });
+ }
+ }
+
+ devLog(
+ "[buildDocContext] available docs:",
+ Object.entries(docIndex).map(([label, info]) => ({
+ label,
+ filename: info.filename,
+ document_id: info.document_id,
+ })),
+ );
+ return { docIndex, docStore };
+}
+
+export async function buildProjectDocContext(
+ projectId: string,
+ _userId: string,
+ db: ReturnType,
+): Promise<{
+ docIndex: DocIndex;
+ docStore: DocStore;
+ folderPaths: Map;
+}> {
+ const docIndex: DocIndex = {};
+ const docStore: DocStore = new Map();
+
+ const [{ data: docs }, { data: folders }] = await Promise.all([
+ db
+ .from("documents")
+ .select("id, current_version_id, status, folder_id")
+ .eq("project_id", projectId)
+ .eq("status", "ready")
+ .order("created_at", { ascending: true }),
+ db
+ .from("project_subfolders")
+ .select("id, name, parent_folder_id")
+ .eq("project_id", projectId),
+ ]);
+ const docList = (docs ?? []) as unknown as {
+ id: string;
+ filename?: string | null;
+ file_type?: string | null;
+ current_version_id?: string | null;
+ active_version_number?: number | null;
+ folder_id?: string | null;
+ storage_path?: string | null;
+ }[];
+ await attachActiveVersionPaths(db, docList);
+
+ // Build folder id → full path map
+ const folderMap = new Map<
+ string,
+ { name: string; parent_folder_id: string | null }
+ >();
+ for (const f of folders ?? [])
+ folderMap.set(f.id, {
+ name: f.name,
+ parent_folder_id: f.parent_folder_id,
+ });
+
+ function resolvePath(folderId: string | null): string {
+ if (!folderId) return "";
+ const parts: string[] = [];
+ let cur: string | null = folderId;
+ while (cur) {
+ const f = folderMap.get(cur);
+ if (!f) break;
+ parts.unshift(f.name);
+ cur = f.parent_folder_id;
+ }
+ return parts.join(" / ");
+ }
+
+ const folderPaths = new Map(); // doc label → folder path
+
+ for (let i = 0; i < docList.length; i++) {
+ const doc = docList[i];
+ if (!doc.storage_path) continue;
+ const docLabel = `doc-${i}`;
+ const filename = doc.filename?.trim() || "Untitled document";
+ docIndex[docLabel] = {
+ document_id: doc.id,
+ filename,
+ version_id: doc.current_version_id ?? null,
+ version_number: doc.active_version_number ?? null,
+ };
+ docStore.set(docLabel, {
+ storage_path: doc.storage_path,
+ file_type: doc.file_type ?? "",
+ filename,
+ });
+ const path = resolvePath(doc.folder_id ?? null);
+ if (path) folderPaths.set(docLabel, path);
+ }
+
+ devLog(
+ "[buildProjectDocContext] available docs:",
+ Object.entries(docIndex).map(([label, info]) => ({
+ label,
+ filename: info.filename,
+ document_id: info.document_id,
+ folder: folderPaths.get(label) ?? null,
+ })),
+ );
+ return { docIndex, docStore, folderPaths };
+}
+
+export async function buildWorkflowStore(
+ userId: string,
+ userEmail: string | null | undefined,
+ db: ReturnType,
+): Promise {
+ const { SYSTEM_ASSISTANT_WORKFLOWS } = await import("../systemWorkflows");
+ const store: WorkflowStore = new Map();
+ const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
+
+ // Seed system workflows first.
+ for (const wf of SYSTEM_ASSISTANT_WORKFLOWS) {
+ store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
+ }
+
+ // Then overlay user-owned assistant workflows.
+ const { data: workflows } = await db
+ .from("workflows")
+ .select("id, title, prompt_md")
+ .eq("user_id", userId)
+ .eq("type", "assistant");
+ for (const wf of workflows ?? []) {
+ if (wf.prompt_md) {
+ store.set(wf.id, { title: wf.title, prompt_md: wf.prompt_md });
+ }
+ }
+
+ // Shared assistant workflows must also be readable by workflow tools.
+ if (normalizedUserEmail) {
+ const { data: shares } = await db
+ .from("workflow_shares")
+ .select("workflow_id")
+ .eq("shared_with_email", normalizedUserEmail);
+ const sharedIds = [
+ ...new Set((shares ?? []).map((share) => share.workflow_id)),
+ ];
+ if (sharedIds.length > 0) {
+ const { data: sharedWorkflows } = await db
+ .from("workflows")
+ .select("id, title, prompt_md")
+ .in("id", sharedIds)
+ .eq("type", "assistant");
+ for (const wf of sharedWorkflows ?? []) {
+ if (wf.prompt_md) {
+ store.set(wf.id, {
+ title: wf.title,
+ prompt_md: wf.prompt_md,
+ });
+ }
+ }
+ }
+ }
+ return store;
+}
diff --git a/backend/src/lib/chat/index.ts b/backend/src/lib/chat/index.ts
new file mode 100644
index 0000000..081c627
--- /dev/null
+++ b/backend/src/lib/chat/index.ts
@@ -0,0 +1,8 @@
+export * from "./types";
+export * from "./prompts";
+export * from "./tools/toolSchemas";
+export * from "./citations";
+export * from "./tools/documentOps";
+export * from "./tools/toolDispatcher";
+export * from "./streaming";
+export * from "./contextBuilders";
diff --git a/backend/src/lib/chat/prompts.ts b/backend/src/lib/chat/prompts.ts
new file mode 100644
index 0000000..ddbec34
--- /dev/null
+++ b/backend/src/lib/chat/prompts.ts
@@ -0,0 +1,82 @@
+import { COURTLISTENER_SYSTEM_PROMPT } from "./tools/courtlistenerTools";
+
+const SYSTEM_PROMPT_BEFORE_RESEARCH = `You are Mike, an AI legal assistant for lawyers and legal professionals. Help analyze documents, answer legal questions, and draft legal documents.
+
+CORE RULES:
+- Be precise, professional, and evidence-aware.
+- Do not fabricate document content.
+- Use at most 10 tool-use rounds per response. Batch independent tool calls and leave room for the final answer.
+- Read each relevant document/version at most once per response. After read_document or fetch_documents returns a document's full text, do not call either tool again for that same document/version in the same response; use the prior result, call find_in_document for targeted checks, or proceed to the next required tool.
+- If the user selects a workflow with [Workflow: (id: )], immediately call read_workflow with that id and follow the workflow before doing anything else.
+- If you need the user to choose between options, clarify a missing premise, or attach one or more documents before you can continue, call ask_inputs with all needed choice and document-upload items in a single tool call. For document-upload items, include a document_types array with short labels for the specific categories of documents you need. After asking, do not continue the substantive task until the user responds in a later message.
+
+DOCUMENT CITATIONS:
+Use document citations only for verbatim evidence from uploaded or generated documents.
+
+In prose, put sequential markers [1], [2], etc. exactly where the cited claim appears. Assign citation refs in first-appearance order and increment by exactly 1 each time: [1], [2], [3], never [1], [2], [3], [4], [5], [8], [9]. The marker number is the citation "ref" value, not a page, footnote, section, clause, or document number.
+
+At the very end of the response, append:
+
+[
+ {"ref": 1, "doc_id": "doc-0", "quotes": [{"page": 3, "quote": "exact verbatim text"}]},
+ {"ref": 2, "doc_id": "doc-1", "quotes": [{"page": "41-42", "quote": "text before page break [[PAGE_BREAK]] text after page break"}]}
+]
+
+
+Citation rules:
+- Every [N] marker must have exactly one matching entry with "ref": N.
+- Citation refs must be contiguous with no skipped numbers. If the response uses N citations, the refs must be exactly 1 through N, and the array should list them in that order.
+- Bracketed numbers like [1] are only citation annotation markers. Do not add brackets to section, clause, schedule, exhibit, paragraph, or list numbering.
+- "doc_id" must be the exact chat-local label you were given, such as "doc-0". Never use a filename or document UUID in "doc_id".
+- Use one citation entry per marker. If one marker needs several passages, use "quotes" with 1 quote by default and at most 3.
+- Keep quotes short, ideally 25 words or fewer, and tightly matched to the claim.
+- "page" means the sequential [Page N] marker in the provided text, not printed page numbers inside the document. Non-spreadsheet unpaginated files may have no [Page N] markers; omit "page" (or use 1) when none is present.
+- For spreadsheet sources (content shown as "## Sheet: " markdown tables with a "Row" column and column-letter headers), cite by cell instead of page: set "sheet" to the sheet name and "cell" to the A1 address or range you are quoting (e.g. "B7" or "B7:C9", combining the column-letter header with the "Row" number). Put the plain cell value in "quote" with no "Row"/column-letter labels or "|" separators. Omit "page" for spreadsheet citations.
+- For a continuous quote crossing two pages, set "page" to "N-M" and include [[PAGE_BREAK]] at the page break. Otherwise, use separate quote objects.
+- For legacy compatibility, you may also include top-level "page" and "quote" matching the first quote.
+- Omit the block when there are no citations.
+
+DOCX GENERATION:
+- If the user asks you to create or draft a document, call generate_docx and provide the downloadable Word document rather than only displaying text inline.
+- If the user asks for a spreadsheet, table workbook, tracker, checklist matrix, or Excel file, call generate_excel.
+- If the user asks for slides, a presentation, pitch deck, board deck, or PowerPoint file, call generate_ppt.
+- If the user asks to revise a document you just generated, call edit_document on that document unless they explicitly want a brand-new document or the change is too broad for coherent editing.
+- Use heading levels in order; do not skip from Heading 1 to Heading 3.
+- Numbering starts at 1, never 0. The generator applies legal numbering automatically. Do not type numbering prefixes into headings.
+- Do not repeat the document title as the first section heading.
+- Contract preambles, party blocks, recitals, and WHEREAS clauses are unnumbered. Begin numbering at the first operative clause or section.
+- Contracts and agreements must end with an unnumbered signature block on a fresh page. Set pageBreak: true on the final section and include signature lines such as By, Name, Title, and Date for each party.
+
+DOCUMENT EDITING:
+- For document edits, call read_document or fetch_documents once for each relevant document/version unless the exact needed text is already available in this response. Do not reread the same document/version before calling edit_document.
+When edit_document adds, deletes, moves, or reorders any numbered clause, section, schedule, exhibit, or list item:
+- Renumber all affected downstream items in the same edit.
+- Update all affected cross-references, including references in recitals, definitions, schedules, and exhibits.
+- Before editing, scan the full document with read_document or find_in_document for affected references.
+- If a reference might point to a shifted number, include the update and explain the reason.
+- When deleting square brackets, delete both "[" and "]".`;
+
+const SYSTEM_PROMPT_AFTER_RESEARCH = `DOCUMENT NAMES IN PROSE:
+- Chat-local labels such as "doc-0" are internal. Use them only in tool arguments and citation JSON.
+- Never show "doc-N" labels to the user in prose, headings, lists, or tool activity text.
+- Refer to documents by filename or a natural description, such as "the NDA draft".
+
+GENERAL GUIDANCE:
+- Cite the exact document or fetched opinion passage for evidence-backed claims.
+- If no documents are provided, answer from legal knowledge.
+- Do not use emojis.
+`;
+
+/**
+ * Assemble the chat system prompt. When `includeResearchTools` is true the
+ * CourtListener (US case-law) research instructions are spliced in; when
+ * false they are omitted entirely so the model is not told about tools it
+ * does not have.
+ */
+export function buildSystemPrompt(includeResearchTools = true): string {
+ return includeResearchTools
+ ? `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${COURTLISTENER_SYSTEM_PROMPT}\n${SYSTEM_PROMPT_AFTER_RESEARCH}`
+ : `${SYSTEM_PROMPT_BEFORE_RESEARCH}\n\n${SYSTEM_PROMPT_AFTER_RESEARCH}`;
+}
+
+export const SYSTEM_PROMPT = buildSystemPrompt(true);
diff --git a/backend/src/lib/chat/streaming.ts b/backend/src/lib/chat/streaming.ts
new file mode 100644
index 0000000..f6ddacb
--- /dev/null
+++ b/backend/src/lib/chat/streaming.ts
@@ -0,0 +1,553 @@
+import {
+ streamChatWithTools,
+ resolveModel,
+ DEFAULT_MAIN_MODEL,
+ type LlmMessage,
+ type OpenAIToolSchema,
+} from "../llm";
+import { safeErrorMessage } from "../safeError";
+import { createServerSupabase } from "../supabase";
+import {
+ buildUserMcpTools,
+ type McpToolEvent,
+} from "../mcpConnectors";
+import {
+ COURTLISTENER_TOOLS,
+ type CaseCitationEvent,
+ type CourtlistenerToolEvent,
+} from "./tools/courtlistenerTools";
+import {
+ type DocStore,
+ type DocIndex,
+ type TabularCellStore,
+ type WorkflowStore,
+ type ToolCall,
+ type AskInputsEvent,
+ type EditAnnotation,
+ devLog,
+} from "./types";
+import { TOOLS, WORKFLOW_TOOLS } from "./tools/toolSchemas";
+import {
+ parseCitationsWithDiagnostics,
+ parsePartialCitationObjects,
+ createCitation,
+ CITATIONS_OPEN_TAG,
+} from "./citations";
+import {
+ runToolCalls,
+ type CourtlistenerTurnState,
+} from "./tools/toolDispatcher";
+import {
+ type TurnEditState,
+ type TurnReadState,
+} from "./tools/documentOps";
+
+
+export type AssistantEvent =
+ | { type: "reasoning"; text: string }
+ | AskInputsEvent
+ | {
+ type: "ask_inputs_response";
+ responses: {
+ id: string;
+ kind: "choice" | "documents";
+ question?: string;
+ answer?: string;
+ filenames?: string[];
+ skipped?: boolean;
+ }[];
+ }
+ | { type: "doc_read"; filename: string; document_id?: string }
+ | {
+ type: "doc_find";
+ filename: string;
+ query: string;
+ total_matches: number;
+ }
+ | {
+ type: "doc_created";
+ filename: string;
+ download_url: string;
+ document_id?: string;
+ version_id?: string;
+ version_number?: number | null;
+ }
+ | { type: "doc_download"; filename: string; download_url: string }
+ | {
+ type: "doc_replicated";
+ /** Source document being copied. */
+ filename: string;
+ count: number;
+ copies: {
+ new_filename: string;
+ document_id: string;
+ version_id: string;
+ }[];
+ }
+ | { type: "workflow_applied"; workflow_id: string; title: string }
+ | {
+ type: "doc_edited";
+ filename: string;
+ document_id: string;
+ version_id: string;
+ /** Per-document monotonic Vn; null if backend couldn't determine it. */
+ version_number: number | null;
+ download_url: string;
+ annotations: EditAnnotation[];
+ }
+ | CaseCitationEvent
+ | CourtlistenerToolEvent
+ | McpToolEvent
+ | { type: "case_opinions"; cluster_id: number; case: unknown }
+ | { type: "content"; text: string }
+ | { type: "error"; message: string };
+
+export class AssistantStreamError extends Error {
+ fullText: string;
+ events: AssistantEvent[];
+
+ constructor(message: string, fullText: string, events: AssistantEvent[]) {
+ super(message);
+ this.name = "AssistantStreamError";
+ this.fullText = fullText;
+ this.events = events;
+ }
+}
+
+export class AssistantStreamAbortError extends AssistantStreamError {
+ constructor(fullText: string, events: AssistantEvent[]) {
+ super("Stream aborted.", fullText, events);
+ this.name = "AbortError";
+ }
+}
+
+class AssistantStreamAskInputsPause extends Error {
+ constructor() {
+ super("Waiting for user input.");
+ this.name = "AssistantStreamAskInputsPause";
+ }
+}
+
+export function isAbortError(error: unknown): boolean {
+ if (!error || typeof error !== "object") return false;
+ const record = error as { name?: unknown; message?: unknown };
+ return (
+ record.name === "AbortError" || record.message === "Stream aborted."
+ );
+}
+
+function throwIfAborted(signal?: AbortSignal) {
+ if (!signal?.aborted) return;
+ const err = new Error("Stream aborted.");
+ err.name = "AbortError";
+ throw err;
+}
+
+export async function runLLMStream(params: {
+ apiMessages: unknown[];
+ docStore: DocStore;
+ docIndex: DocIndex;
+ userId: string;
+ db: ReturnType;
+ write: (s: string) => void;
+ extraTools?: unknown[];
+ includeResearchTools?: boolean;
+ workflowStore?: WorkflowStore;
+ tabularStore?: TabularCellStore;
+ buildCitations?: (fullText: string) => unknown[];
+ model?: string;
+ apiKeys?: import("../llm").UserApiKeys;
+ signal?: AbortSignal;
+ /**
+ * If set, generate_docx will attach created docs to this project so
+ * they appear in the project sidebar. Leave null for general chats —
+ * generated docs still get persisted, but as standalone documents.
+ */
+ projectId?: string | null;
+}): Promise<{
+ fullText: string;
+ events: AssistantEvent[];
+ citations: unknown[];
+}> {
+ const {
+ apiMessages,
+ docStore,
+ docIndex,
+ userId,
+ db,
+ write,
+ extraTools,
+ includeResearchTools = true,
+ workflowStore,
+ tabularStore,
+ buildCitations,
+ model,
+ apiKeys,
+ signal,
+ projectId,
+ } = params;
+ const researchTools = includeResearchTools ? COURTLISTENER_TOOLS : [];
+ const mcpTools = await buildUserMcpTools(userId, db);
+ const baseTools = [...TOOLS, ...researchTools, ...WORKFLOW_TOOLS];
+ const activeTools = extraTools?.length
+ ? [...baseTools, ...mcpTools, ...extraTools]
+ : [...baseTools, ...mcpTools];
+
+ // Extract system prompt; pass remaining turns to the adapter as
+ // plain user/assistant messages.
+ const rawMsgs = apiMessages as { role: string; content: string | null }[];
+ const systemPrompt =
+ rawMsgs[0]?.role === "system" ? (rawMsgs[0].content ?? "") : "";
+ const chatMessages: LlmMessage[] = rawMsgs
+ .filter((m) => m.role !== "system")
+ .map((m) => ({
+ role: m.role === "assistant" ? "assistant" : "user",
+ content: m.content ?? "",
+ }));
+
+ const events: AssistantEvent[] = [];
+ // One assistant turn produces at most one document_versions row per
+ // edited doc. `runToolCalls` fires once per tool-call batch; the model
+ // may emit multiple batches in a single turn, so this map persists
+ // across batches to let subsequent edit_document calls overwrite the
+ // turn's existing version instead of creating a new one.
+ const turnEditState: TurnEditState = new Map();
+ // Suppress repeated full-document reads for the same document/version in
+ // one assistant response. The guard is invalidated when edit_document
+ // changes that document so a post-edit verification read can still happen.
+ const turnReadState: TurnReadState = new Map();
+ const courtlistenerTurnState: CourtlistenerTurnState = {
+ casesByClusterId: new Map(),
+ };
+ let fullText = "";
+ let iterText = "";
+ let iterVisibleText = "";
+ let iterReasoning = "";
+ let visibleTailBuffer = "";
+ let citationsOpenSeen = false;
+ let streamingCitationsBuffer = "";
+ let streamedCitationCount = 0;
+
+ const emitCitationStreamSnapshot = (
+ status: "started" | "partial",
+ citations: unknown[],
+ ) => {
+ if (buildCitations) return;
+ write(`data: ${JSON.stringify({ type: "citations", status, citations })}\n\n`);
+ };
+
+ const streamHiddenCitationContent = (delta: string) => {
+ if (buildCitations || !delta) return;
+ streamingCitationsBuffer += delta;
+ const partial = parsePartialCitationObjects(streamingCitationsBuffer);
+ if (partial.length <= streamedCitationCount) return;
+ streamedCitationCount = partial.length;
+ const citations = partial.map((c) =>
+ createCitation(
+ c,
+ docIndex,
+ courtlistenerTurnState.casesByClusterId,
+ ),
+ );
+ emitCitationStreamSnapshot("partial", citations);
+ };
+
+ const streamVisibleContent = (delta: string) => {
+ if (!delta) return;
+ if (citationsOpenSeen) {
+ streamHiddenCitationContent(delta);
+ return;
+ }
+
+ const combined = visibleTailBuffer + delta;
+ const markerIdx = combined.indexOf(CITATIONS_OPEN_TAG);
+ if (markerIdx >= 0) {
+ const visible = combined.slice(0, markerIdx);
+ if (visible) {
+ iterVisibleText += visible;
+ write(
+ `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
+ );
+ }
+ visibleTailBuffer = "";
+ citationsOpenSeen = true;
+ streamingCitationsBuffer = "";
+ streamedCitationCount = 0;
+ emitCitationStreamSnapshot("started", []);
+ streamHiddenCitationContent(
+ combined.slice(markerIdx + CITATIONS_OPEN_TAG.length),
+ );
+ return;
+ }
+
+ const keep = Math.min(CITATIONS_OPEN_TAG.length - 1, combined.length);
+ const visible = combined.slice(0, combined.length - keep);
+ visibleTailBuffer = combined.slice(combined.length - keep);
+ if (visible) {
+ iterVisibleText += visible;
+ write(
+ `data: ${JSON.stringify({ type: "content_delta", text: visible })}\n\n`,
+ );
+ }
+ };
+
+ const flushVisibleTail = (opts: { emit?: boolean } = {}) => {
+ const emit = opts.emit ?? true;
+ if (citationsOpenSeen || !visibleTailBuffer) {
+ visibleTailBuffer = "";
+ return;
+ }
+ iterVisibleText += visibleTailBuffer;
+ if (emit) {
+ write(
+ `data: ${JSON.stringify({ type: "content_delta", text: visibleTailBuffer })}\n\n`,
+ );
+ }
+ visibleTailBuffer = "";
+ };
+
+ const flushText = (opts: { emit?: boolean } = {}) => {
+ if (!iterText) return;
+ fullText += iterText;
+ flushVisibleTail(opts);
+ if (iterVisibleText) {
+ events.push({ type: "content", text: iterVisibleText });
+ }
+ iterText = "";
+ iterVisibleText = "";
+ visibleTailBuffer = "";
+ citationsOpenSeen = false;
+ streamingCitationsBuffer = "";
+ streamedCitationCount = 0;
+ };
+
+ const flushPartialTurn = (opts: { emit?: boolean } = {}) => {
+ flushText(opts);
+ if (iterReasoning) {
+ events.push({ type: "reasoning", text: iterReasoning });
+ iterReasoning = "";
+ }
+ };
+
+ const selectedModel = resolveModel(model, DEFAULT_MAIN_MODEL);
+
+ try {
+ throwIfAborted(signal);
+ await streamChatWithTools({
+ model: selectedModel,
+ systemPrompt,
+ messages: chatMessages,
+ tools: activeTools as OpenAIToolSchema[],
+ maxIterations: 10,
+ apiKeys,
+ enableThinking: true,
+ abortSignal: signal,
+ callbacks: {
+ onContentDelta: (delta) => {
+ iterText += delta;
+ streamVisibleContent(delta);
+ },
+ onReasoningDelta: (delta) => {
+ iterReasoning += delta;
+ write(
+ `data: ${JSON.stringify({ type: "reasoning_delta", text: delta })}\n\n`,
+ );
+ },
+ onReasoningBlockEnd: () => {
+ if (!iterReasoning) return;
+ events.push({ type: "reasoning", text: iterReasoning });
+ write(`data: ${JSON.stringify({ type: "reasoning_block_end" })}\n\n`);
+ iterReasoning = "";
+ },
+ // Fires after Claude's turn ends with stop_reason=tool_use, before
+ // the tool actually runs. Flushes any buffered assistant text so
+ // it's emitted in chronological order, then signals the client so
+ // it can open a fresh PreResponseWrapper (shows "Working…") while
+ // the tool executes — avoids the dead gap between message_stop
+ // and the first tool-specific event.
+ onToolCallStart: (call) => {
+ flushText();
+ write(
+ `data: ${JSON.stringify({
+ type: "tool_call_start",
+ name: call.name,
+ })}\n\n`,
+ );
+ },
+ },
+ runTools: async (calls) => {
+ throwIfAborted(signal);
+ // Emit any text the model produced before this tool turn so the
+ // UI sees it before the tool results stream in.
+ flushText();
+
+ const toolCalls: ToolCall[] = calls.map((c) => ({
+ id: c.id,
+ function: {
+ name: c.name,
+ arguments: JSON.stringify(c.input),
+ },
+ }));
+ const {
+ toolResults,
+ docsRead,
+ docsFound,
+ docsCreated,
+ docsReplicated,
+ workflowsApplied,
+ docsEdited,
+ askInputsEvents,
+ courtlistenerEvents,
+ caseCitationEvents,
+ mcpEvents,
+ } = await runToolCalls(
+ toolCalls,
+ docStore,
+ userId,
+ db,
+ write,
+ workflowStore,
+ tabularStore,
+ docIndex,
+ turnEditState,
+ turnReadState,
+ projectId,
+ courtlistenerTurnState,
+ apiKeys,
+ );
+ throwIfAborted(signal);
+ for (const r of docsRead) {
+ events.push({
+ type: "doc_read",
+ filename: r.filename,
+ document_id: r.document_id,
+ });
+ }
+ for (const f of docsFound) {
+ events.push({
+ type: "doc_find",
+ filename: f.filename,
+ query: f.query,
+ total_matches: f.total_matches,
+ });
+ }
+ for (const dl of docsCreated) {
+ events.push({
+ type: "doc_created",
+ filename: dl.filename,
+ download_url: dl.download_url,
+ document_id: dl.document_id,
+ version_id: dl.version_id,
+ version_number: dl.version_number ?? null,
+ });
+ }
+ for (const r of docsReplicated) {
+ events.push({
+ type: "doc_replicated",
+ filename: r.filename,
+ count: r.count,
+ copies: r.copies,
+ });
+ }
+ for (const wf of workflowsApplied) {
+ events.push({
+ type: "workflow_applied",
+ workflow_id: wf.workflow_id,
+ title: wf.title,
+ });
+ }
+ for (const e of docsEdited) {
+ events.push({
+ type: "doc_edited",
+ filename: e.filename,
+ document_id: e.document_id,
+ version_id: e.version_id,
+ version_number: e.version_number,
+ download_url: e.download_url,
+ annotations: e.annotations,
+ });
+ }
+ for (const askInputsEvent of askInputsEvents) {
+ write(`data: ${JSON.stringify(askInputsEvent)}\n\n`);
+ events.push(askInputsEvent);
+ }
+ for (const event of courtlistenerEvents) {
+ events.push(event);
+ }
+ for (const event of mcpEvents) {
+ events.push(event);
+ }
+ for (const event of caseCitationEvents) {
+ events.push(event);
+ }
+
+ if (askInputsEvents.length > 0) {
+ throw new AssistantStreamAskInputsPause();
+ }
+
+ // Index alignment would break if any tool branch skips its
+ // push (unhandled tool name, disabled store, guard failure).
+ // Each tool_result already carries its tool_call_id, so key off
+ // that directly — and fall back to an error result for any
+ // tool_use that didn't produce one, so Claude's next request
+ // has a tool_result for every tool_use it sent.
+ const resultByCallId = new Map();
+ for (const r of toolResults) {
+ const row = r as { tool_call_id: string; content?: unknown };
+ resultByCallId.set(row.tool_call_id, String(row.content ?? ""));
+ }
+ return toolCalls.map((c) => ({
+ tool_use_id: c.id,
+ content:
+ resultByCallId.get(c.id) ??
+ JSON.stringify({
+ error: `Tool '${c.function.name}' is not available.`,
+ }),
+ }));
+ },
+ });
+ } catch (err) {
+ if (err instanceof AssistantStreamAskInputsPause) {
+ // The ask_inputs event has already been emitted and persisted in `events`.
+ // Stop this assistant turn here so the model does not add redundant
+ // prose telling the user to answer the picker or attach documents.
+ } else if (isAbortError(err)) {
+ flushPartialTurn({ emit: false });
+ throw new AssistantStreamAbortError(fullText, events);
+ } else {
+ flushPartialTurn();
+ const message = safeErrorMessage(err, "Stream error");
+ events.push({ type: "error", message });
+ throw new AssistantStreamError(message, fullText, events);
+ }
+ }
+
+ flushText();
+
+ // Parse and emit citations from block
+ const { citations: parsedCitations, diagnostics: citationDiagnostics } =
+ parseCitationsWithDiagnostics(fullText);
+ const citations = buildCitations
+ ? buildCitations(fullText)
+ : parsedCitations.map((c) =>
+ createCitation(
+ c,
+ docIndex,
+ courtlistenerTurnState.casesByClusterId,
+ ),
+ );
+ devLog("[chat/stream] final citations", {
+ hasCitationsBlock: citationDiagnostics.hasBlock,
+ citationsBlockLength: citationDiagnostics.rawLength,
+ parseError: citationDiagnostics.error,
+ parsedCitationCount: parsedCitations.length,
+ emittedCitationCount: citations.length,
+ usedCustomCitationBuilder: !!buildCitations,
+ });
+ write(
+ `data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
+ );
+ write("data: [DONE]\n\n");
+
+ return { fullText, events, citations };
+}
diff --git a/backend/src/lib/legalSourcesTools/courtlistenerTools.ts b/backend/src/lib/chat/tools/courtlistenerTools.ts
similarity index 100%
rename from backend/src/lib/legalSourcesTools/courtlistenerTools.ts
rename to backend/src/lib/chat/tools/courtlistenerTools.ts
diff --git a/backend/src/lib/chat/tools/documentOps.ts b/backend/src/lib/chat/tools/documentOps.ts
new file mode 100644
index 0000000..1ca2a55
--- /dev/null
+++ b/backend/src/lib/chat/tools/documentOps.ts
@@ -0,0 +1,1813 @@
+import {
+ downloadFile,
+ generatedDocKey,
+ uploadFile,
+} from "../../storage";
+import { convertedPdfKey, docxToPdf } from "../../convert";
+import { createServerSupabase } from "../../supabase";
+import {
+ applyTrackedEdits,
+ extractDocxBodyText,
+ type EditInput,
+} from "../../docxTrackedChanges";
+import { buildDownloadUrl } from "../../downloadTokens";
+import { loadActiveVersion } from "../../documentVersions";
+import {
+ type DocStore,
+ type DocIndex,
+ type EditAnnotation,
+ STANDARD_FONT_DATA_URL,
+ devLog,
+} from "../types";
+import {
+ contentTypeForDocumentType,
+ isPresentationDocumentType,
+ isSpreadsheetDocumentType,
+ isWordDocumentType,
+ shouldConvertToPdf,
+} from "../../documentTypes";
+import { extractPresentationText } from "../../officeText";
+import { spreadsheetToLLMText } from "../../spreadsheet";
+
+
+export function citationReminder(docLabel: string, filename: string): string {
+ const isSpreadsheet = isSpreadsheetDocumentType(
+ filename.split(".").pop() ?? "",
+ );
+ const shapeLine = isSpreadsheet
+ ? `Use this citation object shape for this spreadsheet: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"sheet": "Sheet name", "cell": "B7", "quote": "plain cell value"}]}. Cite by "sheet" + "cell" (A1 address or range), not by page.`
+ : `Use this citation object shape: {"ref": 1, "doc_id": "${docLabel}", "quotes": [{"page": 1, "quote": "exact verbatim text from the document"}]}. Include top-level "page" and "quote" too only if they match the first quote.`;
+ return [
+ `[Citation requirement for ${docLabel} ("${filename}")]:`,
+ `If your final answer makes any factual claim from this document, include inline [N] markers and append a final JSON block.`,
+ `Every citation entry for this document MUST use "doc_id": "${docLabel}".`,
+ shapeLine,
+ `Do not use "marker" or "text" keys in the citation block; use "ref" and "quotes".`,
+ ].join("\n");
+}
+
+export async function extractPdfText(buf: ArrayBuffer): Promise {
+ try {
+ const pdfjsLib = await import("pdfjs-dist/legacy/build/pdf.mjs" as string);
+ const pdf = await (
+ pdfjsLib as unknown as {
+ getDocument: (opts: unknown) => {
+ promise: Promise<{
+ numPages: number;
+ getPage: (n: number) => Promise<{
+ getTextContent: () => Promise<{
+ items: { str?: string }[];
+ }>;
+ }>;
+ }>;
+ };
+ }
+ ).getDocument({
+ data: new Uint8Array(buf),
+ standardFontDataUrl: STANDARD_FONT_DATA_URL,
+ }).promise;
+ const parts: string[] = [];
+ for (let i = 1; i <= pdf.numPages; i++) {
+ const page = await pdf.getPage(i);
+ const textContent = await page.getTextContent();
+ parts.push(
+ `[Page ${i}]\n${textContent.items.map((it) => it.str ?? "").join(" ")}`,
+ );
+ }
+ return parts.join("\n\n");
+ } catch {
+ return "";
+ }
+}
+
+export async function generateDocx(
+ title: string,
+ sections: unknown[],
+ userId: string,
+ db: ReturnType,
+ options?: { landscape?: boolean; projectId?: string | null },
+) {
+ try {
+ const {
+ Document,
+ Paragraph,
+ HeadingLevel,
+ Packer,
+ Table,
+ TableRow,
+ TableCell,
+ WidthType,
+ BorderStyle,
+ TextRun,
+ AlignmentType,
+ LevelFormat,
+ LevelSuffix,
+ PageOrientation,
+ PageBreak,
+ } = await import("docx");
+
+ const FONT = "Times New Roman";
+ const SIZE = 22; // 11pt in half-points
+
+ type DocChild = InstanceType | InstanceType;
+ const children: DocChild[] = [];
+ children.push(
+ new Paragraph({
+ heading: HeadingLevel.TITLE,
+ spacing: { after: 200 },
+ alignment: AlignmentType.CENTER,
+ children: [
+ new TextRun({
+ text: title.toUpperCase(),
+ color: "000000",
+ font: FONT,
+ size: SIZE,
+ bold: true,
+ }),
+ ],
+ }),
+ );
+
+ const cellBorder = {
+ top: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
+ bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
+ left: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
+ right: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" },
+ };
+
+ const headingLevels = [
+ HeadingLevel.HEADING_1,
+ HeadingLevel.HEADING_2,
+ HeadingLevel.HEADING_3,
+ HeadingLevel.HEADING_4,
+ ];
+ const LEGAL_NUMBERING_REF = "legal-clause-numbering";
+ const legalNumbering = (level: number) => ({
+ reference: LEGAL_NUMBERING_REF,
+ level: Math.max(0, Math.min(level, 4)),
+ });
+ const legalNumberingLevels = [
+ {
+ level: 0,
+ format: LevelFormat.DECIMAL,
+ text: "%1.",
+ alignment: AlignmentType.START,
+ suffix: LevelSuffix.TAB,
+ isLegalNumberingStyle: true,
+ style: {
+ paragraph: { indent: { left: 720, hanging: 720 } },
+ run: {
+ bold: true,
+ color: "000000",
+ font: FONT,
+ size: SIZE,
+ },
+ },
+ },
+ {
+ level: 1,
+ format: LevelFormat.DECIMAL,
+ text: "%1.%2",
+ alignment: AlignmentType.START,
+ suffix: LevelSuffix.TAB,
+ isLegalNumberingStyle: true,
+ style: {
+ paragraph: { indent: { left: 720, hanging: 720 } },
+ run: { color: "000000", font: FONT, size: SIZE },
+ },
+ },
+ {
+ level: 2,
+ format: LevelFormat.LOWER_LETTER,
+ text: "(%3)",
+ alignment: AlignmentType.START,
+ suffix: LevelSuffix.TAB,
+ style: {
+ paragraph: { indent: { left: 1440, hanging: 720 } },
+ run: { color: "000000", font: FONT, size: SIZE },
+ },
+ },
+ {
+ level: 3,
+ format: LevelFormat.LOWER_ROMAN,
+ text: "(%4)",
+ alignment: AlignmentType.START,
+ suffix: LevelSuffix.TAB,
+ style: {
+ paragraph: { indent: { left: 1440, hanging: 720 } },
+ run: { color: "000000", font: FONT, size: SIZE },
+ },
+ },
+ {
+ level: 4,
+ format: LevelFormat.UPPER_LETTER,
+ text: "(%5)",
+ alignment: AlignmentType.START,
+ suffix: LevelSuffix.TAB,
+ style: {
+ paragraph: { indent: { left: 2520, hanging: 720 } },
+ run: { color: "000000", font: FONT, size: SIZE },
+ },
+ },
+ ];
+ const normalizeTable = (
+ table: unknown,
+ ): { headers: string[]; rows: string[][] } | null => {
+ if (!table || typeof table !== "object") return null;
+ const raw = table as { headers?: unknown; rows?: unknown };
+ const headers = Array.isArray(raw.headers)
+ ? raw.headers
+ .map((header) => (typeof header === "string" ? header.trim() : ""))
+ .filter(Boolean)
+ : [];
+ if (headers.length === 0) return null;
+
+ const rawRows = Array.isArray(raw.rows) ? raw.rows : [];
+ const rows = rawRows
+ .filter((row): row is unknown[] => Array.isArray(row))
+ .map((row) =>
+ headers.map((_, i) => (typeof row[i] === "string" ? row[i] : "")),
+ );
+
+ return { headers, rows };
+ };
+ const stripManualNumbering = (
+ value: string,
+ ): { text: string; levelFromPrefix: number | null } => {
+ const match = value.trim().match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/);
+ if (!match) return { text: value.trim(), levelFromPrefix: null };
+ return {
+ text: match[2].trim(),
+ levelFromPrefix: match[1].split(".").length - 1,
+ };
+ };
+ const parseManualListMarker = (
+ value: string,
+ ): { text: string; levelOffset: number | null } => {
+ const trimmed = value.trim();
+ const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i);
+ if (!match) return { text: trimmed, levelOffset: null };
+ const marker = (match[2] ?? match[3] ?? "").toLowerCase();
+ const isRoman =
+ marker === "i" ||
+ (marker.length > 1 &&
+ /^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test(
+ marker,
+ ));
+ return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 };
+ };
+ const normalizeHeadingText = (value: string) =>
+ value
+ .trim()
+ .replace(/[^a-zA-Z0-9]+/g, " ")
+ .trim()
+ .toLowerCase();
+
+ const isTitleLikeFirstHeading = (heading: string, sectionIndex: number) => {
+ if (sectionIndex !== 0) return false;
+ const normalized = normalizeHeadingText(heading);
+ const titleNormalized = normalizeHeadingText(title);
+ if (!normalized || !titleNormalized) return false;
+ if (normalized === titleNormalized) return true;
+ return (
+ titleNormalized.includes(normalized) &&
+ /\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test(
+ normalized,
+ )
+ );
+ };
+
+ const isUnnumberedHeading = (heading: string, sectionIndex: number) => {
+ const normalized = normalizeHeadingText(heading);
+ if (!normalized) return true;
+ if (normalized === "signatures" || normalized === "signature") {
+ return true;
+ }
+ if (isTitleLikeFirstHeading(heading, sectionIndex)) {
+ return true;
+ }
+ if (
+ sectionIndex === 0 &&
+ /^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test(
+ normalized,
+ )
+ ) {
+ return true;
+ }
+ return false;
+ };
+ const isSignatureLine = (value: string) =>
+ /^(?:by|name|title|date):\s*/i.test(value.trim());
+ const looksLikeSignatureBlock = (value: string) => {
+ const lines = value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ if (lines.length === 0) return false;
+ const signatureLineCount = lines.filter(isSignatureLine).length;
+ return signatureLineCount >= 2;
+ };
+ let currentClauseLevel: number | null = null;
+
+ for (const [sectionIndex, section] of (
+ sections as {
+ heading?: string;
+ content?: string;
+ level?: number;
+ pageBreak?: boolean;
+ table?: { headers: string[]; rows: string[][] };
+ }[]
+ ).entries()) {
+ if (section.pageBreak) {
+ children.push(new Paragraph({ children: [new PageBreak()] }));
+ }
+ if (section.heading) {
+ const stripped = stripManualNumbering(section.heading);
+ const isUnnumbered = isUnnumberedHeading(stripped.text, sectionIndex);
+ const skipHeading = isTitleLikeFirstHeading(
+ stripped.text,
+ sectionIndex,
+ );
+ const idx = Math.min(
+ stripped.levelFromPrefix ?? (section.level ?? 1) - 1,
+ 3,
+ );
+ currentClauseLevel = isUnnumbered || skipHeading ? null : idx;
+ const headingText =
+ idx === 0 && !isUnnumbered
+ ? stripped.text.toUpperCase()
+ : stripped.text;
+ if (!skipHeading) {
+ children.push(
+ new Paragraph({
+ heading: headingLevels[idx],
+ numbering: isUnnumbered ? undefined : legalNumbering(idx),
+ spacing: { after: 160 },
+ children: [
+ new TextRun({
+ text: headingText,
+ color: "000000",
+ font: FONT,
+ size: SIZE,
+ bold: true,
+ }),
+ ],
+ }),
+ );
+ }
+ }
+ const normalizedTable = normalizeTable(section.table);
+ if (normalizedTable) {
+ const { headers, rows } = normalizedTable;
+ const tableRows: InstanceType[] = [];
+ // Header row
+ tableRows.push(
+ new TableRow({
+ tableHeader: true,
+ children: headers.map(
+ (h) =>
+ new TableCell({
+ borders: cellBorder,
+ shading: { fill: "F2F2F2" },
+ children: [
+ new Paragraph({
+ children: [
+ new TextRun({
+ text: h,
+ bold: true,
+ font: FONT,
+ size: SIZE,
+ }),
+ ],
+ alignment: AlignmentType.LEFT,
+ }),
+ ],
+ }),
+ ),
+ }),
+ );
+ // Data rows — normalize each row to exactly colCount cells.
+ // LLMs occasionally emit malformed rows (extra fragments from
+ // stray delimiters, or short rows); padding/truncating here
+ // keeps the rendered table aligned to the headers.
+ for (const normalized of rows) {
+ tableRows.push(
+ new TableRow({
+ children: normalized.map(
+ (cell) =>
+ new TableCell({
+ borders: cellBorder,
+ children: [
+ new Paragraph({
+ children: [
+ new TextRun({
+ text: cell,
+ font: FONT,
+ size: SIZE,
+ }),
+ ],
+ }),
+ ],
+ }),
+ ),
+ }),
+ );
+ }
+ children.push(
+ new Table({
+ width: { size: 100, type: WidthType.PERCENTAGE },
+ rows: tableRows,
+ }),
+ );
+ children.push(new Paragraph({ text: "" }));
+ }
+ if (section.content) {
+ let numberedBodyParagraphs = 0;
+ const contentIsSignatureBlock =
+ section.heading &&
+ normalizeHeadingText(section.heading).includes("signature")
+ ? true
+ : looksLikeSignatureBlock(section.content);
+ for (const line of section.content.split("\n")) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/);
+ const rawText = bulletMatch ? bulletMatch[1].trim() : trimmed;
+ const manualList = parseManualListMarker(rawText);
+ const numeric = stripManualNumbering(rawText);
+ const text = bulletMatch
+ ? rawText
+ : manualList.levelOffset !== null
+ ? manualList.text
+ : numeric.text;
+ const inferredLevel =
+ currentClauseLevel === null || contentIsSignatureBlock
+ ? undefined
+ : bulletMatch
+ ? currentClauseLevel + 2
+ : manualList.levelOffset !== null
+ ? currentClauseLevel + manualList.levelOffset
+ : numeric.levelFromPrefix !== null
+ ? numeric.levelFromPrefix
+ : numberedBodyParagraphs === 0
+ ? currentClauseLevel + 1
+ : currentClauseLevel + 2;
+ if (currentClauseLevel !== null) numberedBodyParagraphs++;
+ children.push(
+ new Paragraph({
+ numbering:
+ inferredLevel === undefined
+ ? undefined
+ : legalNumbering(inferredLevel),
+ spacing: { after: 120 },
+ children: [
+ new TextRun({
+ text,
+ font: FONT,
+ size: SIZE,
+ }),
+ ],
+ }),
+ );
+ }
+ }
+ }
+
+ const pageSetup = options?.landscape
+ ? { page: { size: { orientation: PageOrientation.LANDSCAPE } } }
+ : {};
+
+ const doc = new Document({
+ numbering: {
+ config: [
+ {
+ reference: LEGAL_NUMBERING_REF,
+ levels: legalNumberingLevels,
+ },
+ ],
+ },
+ sections: [{ properties: pageSetup, children }],
+ });
+ const buf = await Packer.toBuffer(doc);
+ const zip = await import("jszip");
+ const packageZip = await zip.default.loadAsync(buf);
+ for (const requiredPath of [
+ "[Content_Types].xml",
+ "word/document.xml",
+ "word/_rels/document.xml.rels",
+ ]) {
+ if (!packageZip.file(requiredPath)) {
+ return {
+ error: `Generated DOCX is missing required package part: ${requiredPath}`,
+ };
+ }
+ }
+ const docId = crypto.randomUUID().replace(/-/g, "");
+ const safeTitle =
+ title
+ .replace(/[^a-zA-Z0-9 -]/g, "")
+ .trim()
+ .slice(0, 64) || "document";
+ const filename = `${safeTitle}.docx`;
+ const key = generatedDocKey(userId, docId, filename);
+
+ await uploadFile(
+ key,
+ buf.buffer as ArrayBuffer,
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ );
+ const downloadUrl = buildDownloadUrl(key, filename);
+
+ // Persist to DB so generated docs are first-class documents:
+ // openable in the DocPanel and editable via edit_document. In
+ // project chats we attach to the project so it appears in the
+ // sidebar; in the general chat we leave project_id null and it
+ // stays a standalone document.
+ const { data: docRow, error: docErr } = await db
+ .from("documents")
+ .insert({
+ project_id: options?.projectId ?? null,
+ user_id: userId,
+ status: "ready",
+ })
+ .select("id")
+ .single();
+ if (docErr || !docRow) {
+ return {
+ error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`,
+ };
+ }
+ const documentId = docRow.id as string;
+
+ const { data: versionRow, error: verErr } = await db
+ .from("document_versions")
+ .insert({
+ document_id: documentId,
+ storage_path: key,
+ source: "generated",
+ version_number: 1,
+ filename: filename,
+ file_type: "docx",
+ size_bytes: buf.byteLength,
+ page_count: null,
+ })
+ .select("id")
+ .single();
+ if (verErr || !versionRow) {
+ return {
+ error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`,
+ };
+ }
+ const versionId = versionRow.id as string;
+
+ await db
+ .from("documents")
+ .update({
+ current_version_id: versionId,
+ })
+ .eq("id", documentId);
+
+ return {
+ filename,
+ download_url: downloadUrl,
+ document_id: documentId,
+ version_id: versionId,
+ version_number: 1,
+ storage_path: key,
+ message: `Document '${filename}' has been generated successfully.`,
+ };
+ } catch (e) {
+ return { error: String(e) };
+ }
+}
+
+export function safeGeneratedFilename(title: string, extension: string) {
+ const rawTitle = typeof title === "string" ? title : "document";
+ const safeTitle =
+ rawTitle
+ .replace(/[^a-zA-Z0-9 -]/g, "")
+ .trim()
+ .slice(0, 64) || "document";
+ return `${safeTitle}.${extension}`;
+}
+
+function xmlEscape(value: unknown) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function excelColumnName(index: number) {
+ let n = index + 1;
+ let name = "";
+ while (n > 0) {
+ const mod = (n - 1) % 26;
+ name = String.fromCharCode(65 + mod) + name;
+ n = Math.floor((n - mod) / 26);
+ }
+ return name;
+}
+
+function normalizeSheetName(value: unknown, fallback: string) {
+ const raw = typeof value === "string" && value.trim() ? value.trim() : fallback;
+ return raw.replace(/[:\\/?*[\]]/g, " ").trim().slice(0, 31) || fallback;
+}
+
+function normalizeRows(rows: unknown, colCount: number) {
+ if (!Array.isArray(rows)) return [];
+ return rows
+ .filter((row): row is unknown[] => Array.isArray(row))
+ .map((row) =>
+ Array.from({ length: colCount }, (_, i) =>
+ row[i] == null ? "" : String(row[i]),
+ ),
+ );
+}
+
+async function buildXlsxWorkbook(title: string, sheetsInput: unknown[]) {
+ const JSZip = (await import("jszip")).default;
+ const zip = new JSZip();
+ const sheets = sheetsInput.length ? sheetsInput : [{ name: title, columns: [], rows: [] }];
+
+ const normalizedSheets = sheets.map((sheet, index) => {
+ const raw = (sheet && typeof sheet === "object" ? sheet : {}) as {
+ name?: unknown;
+ columns?: unknown;
+ rows?: unknown;
+ };
+ const columns = Array.isArray(raw.columns)
+ ? raw.columns.map((col) => String(col ?? "")).filter((col) => col.trim())
+ : [];
+ const fallbackColumns = columns.length ? columns : ["Value"];
+ return {
+ name: normalizeSheetName(raw.name, `Sheet ${index + 1}`),
+ columns: fallbackColumns,
+ rows: normalizeRows(raw.rows, fallbackColumns.length),
+ };
+ });
+
+ zip.file(
+ "[Content_Types].xml",
+ `
+
+
+
+
+
+
+${normalizedSheets
+ .map(
+ (_, i) =>
+ ` `,
+ )
+ .join("\n")}
+`,
+ );
+ zip.file(
+ "_rels/.rels",
+ `
+
+
+
+
+`,
+ );
+ zip.file(
+ "docProps/core.xml",
+ `
+
+ ${xmlEscape(title)}
+ Mike
+ Mike
+ ${new Date().toISOString()}
+ ${new Date().toISOString()}
+`,
+ );
+ zip.file(
+ "docProps/app.xml",
+ `
+
+ Mike
+`,
+ );
+ zip.file(
+ "xl/workbook.xml",
+ `
+
+
+${normalizedSheets
+ .map(
+ (sheet, i) =>
+ ` `,
+ )
+ .join("\n")}
+
+`,
+ );
+ zip.file(
+ "xl/_rels/workbook.xml.rels",
+ `
+
+${normalizedSheets
+ .map(
+ (_, i) =>
+ ` `,
+ )
+ .join("\n")}
+`,
+ );
+
+ for (const [sheetIndex, sheet] of normalizedSheets.entries()) {
+ const allRows = [sheet.columns, ...sheet.rows];
+ const rowXml = allRows
+ .map((row, rowIndex) => {
+ const rowNumber = rowIndex + 1;
+ const cellXml = row
+ .map((value, colIndex) => {
+ const ref = `${excelColumnName(colIndex)}${rowNumber}`;
+ return `${xmlEscape(value)}`;
+ })
+ .join("");
+ return `${cellXml}
`;
+ })
+ .join("");
+ const lastRef = `${excelColumnName(Math.max(sheet.columns.length - 1, 0))}${Math.max(allRows.length, 1)}`;
+ zip.file(
+ `xl/worksheets/sheet${sheetIndex + 1}.xml`,
+ `
+
+
+ ${rowXml}
+`,
+ );
+ }
+
+ return zip.generateAsync({ type: "nodebuffer" });
+}
+
+function pptTextParagraphs(lines: string[], opts: { title?: boolean } = {}) {
+ return lines
+ .map((line, index) => {
+ const escaped = xmlEscape(line);
+ const titleAttrs = opts.title ? ' sz="3200" b="1"' : ' sz="2000"';
+ const bullet = !opts.title && index >= 0
+ ? ''
+ : "";
+ return `${bullet}${escaped}`;
+ })
+ .join("");
+}
+
+function pptShape(id: number, name: string, x: number, y: number, cx: number, cy: number, body: string) {
+ return `
+
+
+ ${body}
+`;
+}
+
+async function buildPptxPresentation(title: string, slidesInput: unknown[]) {
+ const JSZip = (await import("jszip")).default;
+ const zip = new JSZip();
+ const rawSlides = slidesInput.length
+ ? slidesInput
+ : [{ title, bullets: ["Generated by Mike"] }];
+ const slides = rawSlides.map((slide, index) => {
+ const raw = (slide && typeof slide === "object" ? slide : {}) as {
+ title?: unknown;
+ bullets?: unknown;
+ };
+ return {
+ title:
+ typeof raw.title === "string" && raw.title.trim()
+ ? raw.title.trim()
+ : index === 0
+ ? title
+ : `Slide ${index + 1}`,
+ bullets: Array.isArray(raw.bullets)
+ ? raw.bullets.map((bullet) => String(bullet ?? "")).filter(Boolean)
+ : [],
+ };
+ });
+
+ zip.file(
+ "[Content_Types].xml",
+ `
+
+
+
+
+
+
+
+
+
+${slides
+ .map(
+ (_, i) =>
+ ` `,
+ )
+ .join("\n")}
+`,
+ );
+ zip.file(
+ "_rels/.rels",
+ `
+
+
+
+
+`,
+ );
+ zip.file(
+ "docProps/core.xml",
+ `
+
+ ${xmlEscape(title)}
+ Mike
+ Mike
+ ${new Date().toISOString()}
+ ${new Date().toISOString()}
+`,
+ );
+ zip.file(
+ "docProps/app.xml",
+ `
+
+ Mike
+ On-screen Show (16:9)
+ ${slides.length}
+`,
+ );
+ zip.file(
+ "ppt/presentation.xml",
+ `
+
+
+
+${slides.map((_, i) => ` `).join("\n")}
+
+
+
+`,
+ );
+ zip.file(
+ "ppt/_rels/presentation.xml.rels",
+ `
+
+${slides
+ .map(
+ (_, i) =>
+ ` `,
+ )
+ .join("\n")}
+
+
+`,
+ );
+ zip.file(
+ "ppt/slideMasters/slideMaster1.xml",
+ `
+
+
+
+`,
+ );
+ zip.file(
+ "ppt/slideMasters/_rels/slideMaster1.xml.rels",
+ `
+
+
+
+`,
+ );
+ zip.file(
+ "ppt/slideLayouts/slideLayout1.xml",
+ `
+
+
+`,
+ );
+ zip.file(
+ "ppt/theme/theme1.xml",
+ `
+
+
+
+
+
+
+`,
+ );
+
+ for (const [index, slide] of slides.entries()) {
+ const bullets = slide.bullets.length ? slide.bullets : [""];
+ zip.file(
+ `ppt/slides/slide${index + 1}.xml`,
+ `
+
+
+
+
+
+
+ ${pptShape(2, "Title", 685800, 457200, 10820400, 914400, pptTextParagraphs([slide.title], { title: true }))}
+ ${pptShape(3, "Content", 914400, 1600200, 10363200, 4343400, pptTextParagraphs(bullets))}
+
+
+`,
+ );
+ zip.file(
+ `ppt/slides/_rels/slide${index + 1}.xml.rels`,
+ `
+
+
+`,
+ );
+ }
+
+ return zip.generateAsync({ type: "nodebuffer" });
+}
+
+async function persistGeneratedFile(params: {
+ title: string;
+ extension: "xlsx" | "pptx";
+ buffer: Buffer;
+ userId: string;
+ db: ReturnType;
+ projectId?: string | null;
+}) {
+ const { title, extension, buffer, userId, db, projectId } = params;
+ const docId = crypto.randomUUID().replace(/-/g, "");
+ const filename = safeGeneratedFilename(title, extension);
+ const key = generatedDocKey(userId, docId, filename);
+ await uploadFile(
+ key,
+ buffer.buffer.slice(
+ buffer.byteOffset,
+ buffer.byteOffset + buffer.byteLength,
+ ) as ArrayBuffer,
+ contentTypeForDocumentType(extension),
+ );
+
+ let pdfStoragePath: string | null = null;
+ if (shouldConvertToPdf(extension)) {
+ try {
+ const pdfBuf = await docxToPdf(buffer);
+ const pdfKey = convertedPdfKey(userId, docId);
+ await uploadFile(
+ pdfKey,
+ pdfBuf.buffer.slice(
+ pdfBuf.byteOffset,
+ pdfBuf.byteOffset + pdfBuf.byteLength,
+ ) as ArrayBuffer,
+ "application/pdf",
+ );
+ pdfStoragePath = pdfKey;
+ } catch (err) {
+ devLog(`[generate_${extension}] Office→PDF conversion failed:`, err);
+ }
+ }
+
+ const downloadUrl = buildDownloadUrl(key, filename);
+ const { data: docRow, error: docErr } = await db
+ .from("documents")
+ .insert({
+ project_id: projectId ?? null,
+ user_id: userId,
+ status: "ready",
+ })
+ .select("id")
+ .single();
+ if (docErr || !docRow) {
+ return {
+ error: `Failed to record generated document: ${docErr?.message ?? "unknown"}`,
+ };
+ }
+ const documentId = docRow.id as string;
+
+ const { data: versionRow, error: verErr } = await db
+ .from("document_versions")
+ .insert({
+ document_id: documentId,
+ storage_path: key,
+ pdf_storage_path: pdfStoragePath,
+ source: "generated",
+ version_number: 1,
+ filename,
+ file_type: extension,
+ size_bytes: buffer.byteLength,
+ page_count: null,
+ })
+ .select("id")
+ .single();
+ if (verErr || !versionRow) {
+ return {
+ error: `Failed to record generated document version: ${verErr?.message ?? "unknown"}`,
+ };
+ }
+ const versionId = versionRow.id as string;
+
+ await db
+ .from("documents")
+ .update({ current_version_id: versionId })
+ .eq("id", documentId);
+
+ return {
+ filename,
+ download_url: downloadUrl,
+ document_id: documentId,
+ version_id: versionId,
+ version_number: 1,
+ storage_path: key,
+ message: `Document '${filename}' has been generated successfully.`,
+ };
+}
+
+export async function generateExcel(
+ title: string,
+ sheets: unknown[],
+ userId: string,
+ db: ReturnType,
+ options?: { projectId?: string | null },
+) {
+ try {
+ const normalizedTitle = typeof title === "string" ? title : "Workbook";
+ const buffer = await buildXlsxWorkbook(
+ normalizedTitle,
+ Array.isArray(sheets) ? sheets : [],
+ );
+ return persistGeneratedFile({
+ title: normalizedTitle,
+ extension: "xlsx",
+ buffer,
+ userId,
+ db,
+ projectId: options?.projectId ?? null,
+ });
+ } catch (e) {
+ return { error: String(e) };
+ }
+}
+
+export async function generatePpt(
+ title: string,
+ slides: unknown[],
+ userId: string,
+ db: ReturnType,
+ options?: { projectId?: string | null },
+) {
+ try {
+ const normalizedTitle = typeof title === "string" ? title : "Presentation";
+ const buffer = await buildPptxPresentation(
+ normalizedTitle,
+ Array.isArray(slides) ? slides : [],
+ );
+ return persistGeneratedFile({
+ title: normalizedTitle,
+ extension: "pptx",
+ buffer,
+ userId,
+ db,
+ projectId: options?.projectId ?? null,
+ });
+ } catch (e) {
+ return { error: String(e) };
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Document version helpers (DOCX tracked-change editing)
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve the current .docx bytes for a document, preferring the active
+ * tracked-changes version if one exists, else the original upload.
+ */
+export async function loadCurrentVersionBytes(
+ documentId: string,
+ db: ReturnType,
+): Promise<{ bytes: Buffer; storage_path: string } | null> {
+ const active = await loadActiveVersion(documentId, db);
+ if (!active) return null;
+ const raw = await downloadFile(active.storage_path);
+ if (!raw) return null;
+ return { bytes: Buffer.from(raw), storage_path: active.storage_path };
+}
+
+/**
+ * Ensure the document has a document_versions row for the current upload.
+ * Called before writing the first 'assistant_edit' row so the history is
+ * complete. Idempotent.
+ */
+export async function runEditDocument(params: {
+ documentId: string;
+ userId: string;
+ edits: EditInput[];
+ db: ReturnType;
+ /**
+ * If provided, append these edits to the existing turn-scoped version
+ * (overwrites the file at storagePath and reuses the document_versions
+ * row) instead of creating a new version. Used to collapse multiple
+ * edit_document tool calls within a single assistant turn into one
+ * version.
+ */
+ reuseVersion?: {
+ versionId: string;
+ versionNumber: number;
+ storagePath: string;
+ };
+}): Promise<
+ | {
+ ok: true;
+ version_id: string;
+ version_number: number;
+ storage_path: string;
+ download_url: string;
+ annotations: EditAnnotation[];
+ errors: { index: number; reason: string }[];
+ }
+ | { ok: false; error: string }
+> {
+ const { documentId, userId, edits, db, reuseVersion } = params;
+
+ const { data: doc } = await db
+ .from("documents")
+ .select("id")
+ .eq("id", documentId)
+ .single();
+ if (!doc) return { ok: false, error: "Document not found." };
+
+ const activeVersion = await loadActiveVersion(documentId, db);
+ let versionFilename =
+ activeVersion?.filename?.trim() || "Untitled document";
+
+ const current = await loadCurrentVersionBytes(documentId, db);
+ if (!current) return { ok: false, error: "Could not load document bytes." };
+
+ const {
+ bytes: editedBytes,
+ changes,
+ errors,
+ } = await applyTrackedEdits(current.bytes, edits, { author: "Mike" });
+
+ if (changes.length === 0) {
+ return {
+ ok: false,
+ error:
+ errors[0]?.reason ??
+ "No edits could be applied. Refine context_before/context_after and retry.",
+ };
+ }
+
+ const ab = editedBytes.buffer.slice(
+ editedBytes.byteOffset,
+ editedBytes.byteOffset + editedBytes.byteLength,
+ ) as ArrayBuffer;
+
+ let versionRowId: string;
+ let newPath: string;
+ let nextVersionNumber: number;
+
+ if (reuseVersion) {
+ // Overwrite the existing turn version's file in place. The version
+ // row, version_number, and current_version_id all already point here.
+ newPath = reuseVersion.storagePath;
+ versionRowId = reuseVersion.versionId;
+ nextVersionNumber = reuseVersion.versionNumber;
+ await uploadFile(
+ newPath,
+ ab,
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ );
+ await db
+ .from("document_versions")
+ .update({
+ file_type: "docx",
+ size_bytes: editedBytes.byteLength,
+ page_count: null,
+ })
+ .eq("id", versionRowId);
+ } else {
+ const versionId = crypto.randomUUID().replace(/-/g, "");
+ newPath = `documents/${userId}/${documentId}/edits/${versionId}.docx`;
+ await uploadFile(
+ newPath,
+ ab,
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ );
+
+ // Per-document sequential number for the new assistant_edit
+ // version. The counter spans upload + user_upload + assistant_edit
+ // so the original upload is V1 and the first assistant edit is V2.
+ const { data: maxRow } = await db
+ .from("document_versions")
+ .select("version_number")
+ .eq("document_id", documentId)
+ .in("source", ["upload", "user_upload", "assistant_edit"])
+ .order("version_number", { ascending: false, nullsFirst: false })
+ .limit(1)
+ .maybeSingle();
+ nextVersionNumber = ((maxRow?.version_number as number | null) ?? 1) + 1;
+
+ // Inherit the filename from the most recent prior version so
+ // user-applied renames carry forward through further edits. Malformed
+ // legacy rows without a filename get a neutral placeholder, not the
+ // parent document filename. We intentionally do NOT append "[Edited Vn]"
+ // — the version number is surfaced separately as a tag in the UI.
+ const { data: prevRow } = await db
+ .from("document_versions")
+ .select("filename, created_at")
+ .eq("document_id", documentId)
+ .order("created_at", { ascending: false })
+ .limit(1)
+ .maybeSingle();
+ const inheritedFilename =
+ (prevRow?.filename as string | null)?.trim() || "Untitled document";
+ versionFilename = inheritedFilename;
+
+ const { data: versionRow, error: verErr } = await db
+ .from("document_versions")
+ .insert({
+ document_id: documentId,
+ storage_path: newPath,
+ source: "assistant_edit",
+ version_number: nextVersionNumber,
+ filename: inheritedFilename,
+ file_type: "docx",
+ size_bytes: editedBytes.byteLength,
+ page_count: null,
+ })
+ .select("id")
+ .single();
+ if (verErr || !versionRow) {
+ return { ok: false, error: "Failed to record document version." };
+ }
+ versionRowId = versionRow.id as string;
+ }
+
+ // Insert one row per change
+ const editRows = changes.map((c) => ({
+ document_id: documentId,
+ version_id: versionRowId,
+ change_id: c.id,
+ del_w_id: c.delId ?? null,
+ ins_w_id: c.insId ?? null,
+ deleted_text: c.deletedText,
+ inserted_text: c.insertedText,
+ context_before: c.contextBefore ?? "",
+ context_after: c.contextAfter ?? "",
+ status: "pending" as const,
+ }));
+ const { data: insertedEdits, error: editsErr } = await db
+ .from("document_edits")
+ .insert(editRows)
+ .select(
+ "id, change_id, del_w_id, ins_w_id, deleted_text, inserted_text, context_before, context_after",
+ );
+
+ if (editsErr || !insertedEdits) {
+ return { ok: false, error: "Failed to record edits." };
+ }
+
+ await db
+ .from("documents")
+ .update({
+ current_version_id: versionRowId,
+ })
+ .eq("id", documentId);
+
+ const annotations: EditAnnotation[] = insertedEdits.map(
+ (r: {
+ id: string;
+ change_id: string;
+ deleted_text: string;
+ inserted_text: string;
+ context_before: string | null;
+ context_after: string | null;
+ }) => {
+ const src = changes.find((c) => c.id === r.change_id);
+ return {
+ kind: "edit",
+ edit_id: r.id,
+ document_id: documentId,
+ version_id: versionRowId,
+ version_number: nextVersionNumber,
+ change_id: r.change_id,
+ del_w_id: src?.delId,
+ ins_w_id: src?.insId,
+ deleted_text: r.deleted_text ?? "",
+ inserted_text: r.inserted_text ?? "",
+ context_before: r.context_before ?? "",
+ context_after: r.context_after ?? "",
+ reason: src?.reason,
+ status: "pending",
+ };
+ },
+ );
+
+ // Persistent, non-expiring permalink. The backend streams fresh bytes
+ // on each request, so this URL stays valid as long as the file exists.
+ const resolvedFilename = versionFilename.trim() || "Untitled document.docx";
+ const permalink = buildDownloadUrl(newPath, resolvedFilename);
+
+ return {
+ ok: true,
+ version_id: versionRowId,
+ version_number: nextVersionNumber,
+ storage_path: newPath,
+ download_url: permalink,
+ annotations,
+ errors,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Tool dispatch
+// ---------------------------------------------------------------------------
+
+export async function getTurnReadIdentity(params: {
+ docLabel: string;
+ docStore: DocStore;
+ docIndex?: DocIndex;
+ db?: ReturnType;
+}): Promise<{
+ key: string;
+ docLabel: string;
+ filename: string;
+ documentId?: string;
+ versionId?: string | null;
+ storagePath: string;
+} | null> {
+ const { docLabel, docStore, docIndex, db } = params;
+ const docInfo = docStore.get(docLabel);
+ if (!docInfo) return null;
+
+ const documentId = docIndex?.[docLabel]?.document_id;
+ if (documentId && db) {
+ const active = await loadActiveVersion(documentId, db);
+ if (active?.storage_path) {
+ return {
+ key: `${documentId}:${active.id}`,
+ docLabel,
+ filename: docInfo.filename,
+ documentId,
+ versionId: active.id,
+ storagePath: active.storage_path,
+ };
+ }
+ }
+
+ return {
+ key: `${documentId ?? docLabel}:${docInfo.storage_path}`,
+ docLabel,
+ filename: docInfo.filename,
+ documentId,
+ versionId: docIndex?.[docLabel]?.version_id ?? null,
+ storagePath: docInfo.storage_path,
+ };
+}
+
+export function duplicateReadDocumentResult(identity: {
+ docLabel: string;
+ filename: string;
+ documentId?: string;
+ versionId?: string | null;
+}) {
+ return JSON.stringify({
+ ok: true,
+ already_read: true,
+ doc_id: identity.docLabel,
+ filename: identity.filename,
+ document_id: identity.documentId,
+ version_id: identity.versionId ?? null,
+ content:
+ "This document/version was already read earlier in this response. The full text is not repeated to avoid unnecessary token use.",
+ next_required_action:
+ "Use the prior read_document/fetch_documents result, call find_in_document for targeted checks, or proceed to edit_document.",
+ });
+}
+
+export function clearTurnReadsForDocument(
+ turnReadState: TurnReadState | undefined,
+ documentId: string,
+) {
+ if (!turnReadState) return;
+ for (const [key, value] of turnReadState.entries()) {
+ if (value.documentId === documentId) turnReadState.delete(key);
+ }
+}
+
+export async function readDocumentContent(
+ docLabel: string,
+ docStore: DocStore,
+ write: (s: string) => void,
+ docIndex?: DocIndex,
+ db?: ReturnType,
+ opts?: { emitEvents?: boolean },
+): Promise {
+ const emitEvents = opts?.emitEvents ?? true;
+ devLog(`[read_document] called with docLabel="${docLabel}"`);
+ const docInfo = docStore.get(docLabel);
+ if (!docInfo) {
+ devLog(
+ `[read_document] MISS — docLabel "${docLabel}" not in docStore. Known labels:`,
+ Array.from(docStore.keys()),
+ );
+ return "Document not found.";
+ }
+ devLog(
+ `[read_document] docInfo: filename="${docInfo.filename}", file_type="${docInfo.file_type}", storage_path="${docInfo.storage_path}"`,
+ );
+
+ const documentId = docIndex?.[docLabel]?.document_id;
+ const emitDocRead = () => {
+ if (!emitEvents) return;
+ write(
+ `data: ${JSON.stringify({
+ type: "doc_read",
+ filename: docInfo.filename,
+ document_id: documentId,
+ })}\n\n`,
+ );
+ };
+ if (emitEvents)
+ write(
+ `data: ${JSON.stringify({
+ type: "doc_read_start",
+ filename: docInfo.filename,
+ document_id: documentId,
+ })}\n\n`,
+ );
+ try {
+ // Prefer the current tracked-changes version (if any) so read_document
+ // reflects accepted/pending edits rather than the original upload.
+ let raw: ArrayBuffer | null = null;
+ let sourcePath = docInfo.storage_path;
+ if (documentId && db) {
+ const current = await loadCurrentVersionBytes(documentId, db);
+ if (current) {
+ raw = current.bytes.buffer.slice(
+ current.bytes.byteOffset,
+ current.bytes.byteOffset + current.bytes.byteLength,
+ ) as ArrayBuffer;
+ sourcePath = current.storage_path;
+ devLog(
+ `[read_document] using current version path="${sourcePath}" (bytes=${raw.byteLength})`,
+ );
+ } else {
+ devLog(
+ `[read_document] loadCurrentVersionBytes returned null for documentId="${documentId}", falling back to original storage_path`,
+ );
+ }
+ }
+ if (!raw) {
+ raw = await downloadFile(docInfo.storage_path);
+ if (raw) {
+ devLog(
+ `[read_document] fallback download from storage_path="${docInfo.storage_path}" (bytes=${raw.byteLength})`,
+ );
+ }
+ }
+ if (!raw) {
+ devLog(
+ `[read_document] FAILED to download any bytes for docLabel="${docLabel}" (tried path="${sourcePath}")`,
+ );
+ emitDocRead();
+ return "Document could not be read.";
+ }
+ // Log the first 8 bytes so we can identify real file format regardless
+ // of the declared file_type. Valid .docx starts with "PK\x03\x04"
+ // (zip). Legacy .doc starts with "\xD0\xCF\x11\xE0" (OLE/CFB).
+ // %PDF-1 is a PDF even if mislabeled. Truncated uploads show as all-zero.
+ {
+ const head = Buffer.from(raw).subarray(0, 8);
+ const hex = head.toString("hex");
+ const ascii = head.toString("binary").replace(/[^\x20-\x7e]/g, ".");
+ devLog(
+ `[read_document] magic bytes hex=${hex} ascii="${ascii}" for filename="${docInfo.filename}"`,
+ );
+ }
+ let text: string;
+ const fileType = docInfo.file_type?.toLowerCase?.() ?? "";
+ if (fileType === "pdf") {
+ text = await extractPdfText(raw);
+ devLog(
+ `[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ } else if (fileType === "docx") {
+ // Use the same flattening as the edit_document matcher so the
+ // LLM sees exactly the characters it can anchor against.
+ text = await extractDocxBodyText(Buffer.from(raw));
+ devLog(
+ `[read_document] docx extractDocxBodyText length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ if (!text) {
+ devLog(
+ `[read_document] docx accepted-view extractor returned empty, falling back to mammoth for filename="${docInfo.filename}"`,
+ );
+ const mammoth = await import("mammoth");
+ const result = await mammoth.extractRawText({
+ buffer: Buffer.from(raw),
+ });
+ text = result.value;
+ devLog(
+ `[read_document] docx mammoth fallback length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ }
+ } else if (isSpreadsheetDocumentType(fileType)) {
+ // SheetJS reads .xlsx/.xlsm/.xls directly (no PDF detour), emitting a
+ // cell-addressed markdown view with Excel-formatted values.
+ text = spreadsheetToLLMText(Buffer.from(raw));
+ devLog(
+ `[read_document] spreadsheet extracted length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ } else if (fileType === "pptx") {
+ text = await extractPresentationText(Buffer.from(raw));
+ devLog(
+ `[read_document] presentation extracted length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ } else if (
+ isPresentationDocumentType(fileType) ||
+ isWordDocumentType(fileType)
+ ) {
+ devLog(
+ `[read_document] legacy Office file_type="${fileType}" for filename="${docInfo.filename}", converting to pdf for text extraction`,
+ );
+ const pdfBuf = await docxToPdf(Buffer.from(raw));
+ text = await extractPdfText(
+ pdfBuf.buffer.slice(
+ pdfBuf.byteOffset,
+ pdfBuf.byteOffset + pdfBuf.byteLength,
+ ) as ArrayBuffer,
+ );
+ devLog(
+ `[read_document] legacy Office PDF extraction length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ } else {
+ devLog(
+ `[read_document] unknown file_type="${docInfo.file_type}" for filename="${docInfo.filename}", trying mammoth`,
+ );
+ const mammoth = await import("mammoth");
+ const result = await mammoth.extractRawText({
+ buffer: Buffer.from(raw),
+ });
+ text = result.value;
+ devLog(
+ `[read_document] mammoth length=${text.length} for filename="${docInfo.filename}"`,
+ );
+ }
+ devLog(
+ `[read_document] DONE filename="${docInfo.filename}" finalTextLength=${text.length} firstChars=${JSON.stringify(text.slice(0, 120))}`,
+ );
+ emitDocRead();
+ return text;
+ } catch (err) {
+ devLog(
+ `[read_document] THREW for docLabel="${docLabel}" filename="${docInfo.filename}":`,
+ err,
+ );
+ if (emitEvents)
+ write(
+ `data: ${JSON.stringify({ type: "doc_read", filename: docInfo.filename })}\n\n`,
+ );
+ return "Document could not be read.";
+ }
+}
+
+/**
+ * Build a whitespace-collapsed, lowercased copy of `text`, plus a map from
+ * each character index in the normalized form back to the corresponding
+ * index in the original text. Used by `findInDocumentContent` so matches
+ * are tolerant of case + whitespace variance but can still return the
+ * exact original excerpt.
+ */
+function normalizeWithMap(text: string): { norm: string; origIdx: number[] } {
+ const norm: string[] = [];
+ const origIdx: number[] = [];
+ let prevSpace = false;
+ for (let i = 0; i < text.length; i++) {
+ const ch = text[i];
+ if (/\s/.test(ch)) {
+ if (!prevSpace) {
+ norm.push(" ");
+ origIdx.push(i);
+ prevSpace = true;
+ }
+ } else {
+ norm.push(ch.toLowerCase());
+ origIdx.push(i);
+ prevSpace = false;
+ }
+ }
+ return { norm: norm.join(""), origIdx };
+}
+
+function normalizeQuery(q: string): string {
+ return q.trim().replace(/\s+/g, " ").toLowerCase();
+}
+
+export type TextMatch = {
+ index: number;
+ excerpt: string;
+ context: string;
+};
+
+export function findTextMatches(params: {
+ text: string;
+ query: string;
+ maxResults: number;
+ contextChars: number;
+ startIndex?: number;
+}): { hits: TextMatch[]; totalMatches: number } {
+ const { text, query, maxResults, contextChars, startIndex = 0 } = params;
+ const { norm, origIdx } = normalizeWithMap(text);
+ const needle = normalizeQuery(query);
+ const hits: TextMatch[] = [];
+ let totalMatches = 0;
+ if (!needle) return { hits, totalMatches };
+
+ let from = 0;
+ while (from <= norm.length - needle.length) {
+ const pos = norm.indexOf(needle, from);
+ if (pos < 0) break;
+ const endNormPos = pos + needle.length;
+ const origStart = origIdx[pos] ?? 0;
+ const origEnd =
+ endNormPos - 1 < origIdx.length
+ ? origIdx[endNormPos - 1] + 1
+ : text.length;
+ if (hits.length < maxResults) {
+ const ctxStart = Math.max(0, origStart - contextChars);
+ const ctxEnd = Math.min(text.length, origEnd + contextChars);
+ hits.push({
+ index: startIndex + hits.length,
+ excerpt: text.slice(origStart, origEnd),
+ context:
+ (ctxStart > 0 ? "…" : "") +
+ text.slice(ctxStart, ctxEnd).replace(/\s+/g, " ").trim() +
+ (ctxEnd < text.length ? "…" : ""),
+ });
+ }
+ totalMatches++;
+ from = pos + Math.max(1, needle.length);
+ }
+
+ return { hits, totalMatches };
+}
+
+/**
+ * Ctrl+F helper. Returns a JSON-serializable result with up to `maxResults`
+ * hits, each containing the original-text excerpt plus surrounding context.
+ */
+export async function findInDocumentContent(params: {
+ docLabel: string;
+ query: string;
+ maxResults?: number;
+ contextChars?: number;
+ docStore: DocStore;
+ write: (s: string) => void;
+ docIndex?: DocIndex;
+ db?: ReturnType;
+}): Promise {
+ const {
+ docLabel,
+ query,
+ maxResults = 20,
+ contextChars = 80,
+ docStore,
+ write,
+ docIndex,
+ db,
+ } = params;
+
+ if (!query || !query.trim()) {
+ return JSON.stringify({ ok: false, error: "Empty query." });
+ }
+
+ const docInfo = docStore.get(docLabel);
+ if (!docInfo) {
+ return JSON.stringify({
+ ok: false,
+ error: `Document '${docLabel}' not found.`,
+ });
+ }
+
+ // Announce the search to the UI, then reuse readDocumentContent for its
+ // fallbacks — but suppress its own doc_read events so the user only sees
+ // the doc_find block (not a competing doc_read block for the same op).
+ write(
+ `data: ${JSON.stringify({
+ type: "doc_find_start",
+ filename: docInfo.filename,
+ query,
+ })}\n\n`,
+ );
+
+ const text = await readDocumentContent(
+ docLabel,
+ docStore,
+ write,
+ docIndex,
+ db,
+ { emitEvents: false },
+ );
+ if (!text || text === "Document could not be read.") {
+ write(
+ `data: ${JSON.stringify({
+ type: "doc_find",
+ filename: docInfo.filename,
+ query,
+ total_matches: 0,
+ })}\n\n`,
+ );
+ return JSON.stringify({
+ ok: false,
+ filename: docInfo.filename,
+ error: "Document could not be read.",
+ });
+ }
+
+ const needle = normalizeQuery(query);
+ if (!needle) {
+ return JSON.stringify({
+ ok: false,
+ error: "Empty query after normalization.",
+ });
+ }
+
+ const { hits, totalMatches } = findTextMatches({
+ text,
+ query,
+ maxResults,
+ contextChars,
+ });
+
+ write(
+ `data: ${JSON.stringify({
+ type: "doc_find",
+ filename: docInfo.filename,
+ query,
+ total_matches: totalMatches,
+ })}\n\n`,
+ );
+
+ return JSON.stringify({
+ ok: true,
+ filename: docInfo.filename,
+ query,
+ total_matches: totalMatches,
+ returned: hits.length,
+ truncated: totalMatches > hits.length,
+ hits,
+ });
+}
+
+export type DocEditedResult = {
+ filename: string;
+ document_id: string;
+ version_id: string;
+ version_number: number | null;
+ download_url: string;
+ annotations: EditAnnotation[];
+};
+
+export type TurnEditState = Map<
+ string,
+ { versionId: string; versionNumber: number; storagePath: string }
+>;
+
+export type TurnReadState = Map<
+ string,
+ {
+ docLabel: string;
+ filename: string;
+ documentId?: string;
+ versionId?: string | null;
+ storagePath: string;
+ }
+>;
+
+export type DocCreatedResult = {
+ filename: string;
+ download_url: string;
+ document_id?: string;
+ version_id?: string;
+ version_number?: number | null;
+};
+
+export type DocReplicatedResult = {
+ /** Filename of the source document being copied. */
+ filename: string;
+ /** How many copies were produced in this single tool call. */
+ count: number;
+ /** One entry per new copy. */
+ copies: {
+ new_filename: string;
+ document_id: string;
+ version_id: string;
+ }[];
+};
diff --git a/backend/src/lib/chat/tools/toolDispatcher.ts b/backend/src/lib/chat/tools/toolDispatcher.ts
new file mode 100644
index 0000000..118227c
--- /dev/null
+++ b/backend/src/lib/chat/tools/toolDispatcher.ts
@@ -0,0 +1,1896 @@
+import {
+ getCourtlistenerCases,
+ searchCourtlistenerCaseLaw,
+ verifyCourtlistenerCitations,
+} from "../../courtlistener";
+import {
+ COURTLISTENER_TOOL_NAMES,
+ type CaseCitationEvent,
+ type CourtlistenerToolEvent,
+} from "./courtlistenerTools";
+import {
+ executeMcpToolCall,
+ type McpToolEvent,
+} from "../../mcpConnectors";
+import { createServerSupabase } from "../../supabase";
+import {
+ type DocStore,
+ type DocIndex,
+ type TabularCellStore,
+ type WorkflowStore,
+ type ToolCall,
+ type AskInputItem,
+ type AskInputOption,
+ type AskInputsEvent,
+ devLog,
+ resolveDocLabel,
+} from "../types";
+import {
+ downloadFile,
+ storageKey,
+ uploadFile,
+} from "../../storage";
+import { convertedPdfKey } from "../../convert";
+import { contentTypeForDocumentType } from "../../documentTypes";
+import { buildDownloadUrl } from "../../downloadTokens";
+import { loadActiveVersion } from "../../documentVersions";
+import { type EditInput } from "../../docxTrackedChanges";
+import {
+ citationReminder,
+ generateDocx,
+ generateExcel,
+ generatePpt,
+ getTurnReadIdentity,
+ duplicateReadDocumentResult,
+ clearTurnReadsForDocument,
+ readDocumentContent,
+ findInDocumentContent,
+ findTextMatches,
+ runEditDocument,
+ safeGeneratedFilename,
+ type DocEditedResult,
+ type TurnEditState,
+ type TurnReadState,
+ type DocCreatedResult,
+ type DocReplicatedResult,
+ type TextMatch,
+} from "./documentOps";
+
+
+type CourtlistenerCaseRecord = {
+ clusterId: number;
+ caseName: string | null;
+ citations: string[];
+ url: string | null;
+ pdfUrl: string | null;
+ dateFiled: string | null;
+ opinions?: unknown[];
+};
+
+type CourtlistenerCaseInput = {
+ clusterId?: number | null;
+ caseName?: string | null;
+ citation?: string | null;
+ citations?: string[];
+ url?: string | null;
+ pdfUrl?: string | null;
+ dateFiled?: string | null;
+ opinions?: unknown[];
+};
+
+export type CourtlistenerTurnState = {
+ casesByClusterId: Map;
+};
+
+function nonEmpty(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function cleanAskInputString(value: unknown, fallback = ""): string {
+ const text = typeof value === "string" ? value.trim() : "";
+ return text || fallback;
+}
+
+function normalizeAskInputsEvent(args: Record): AskInputsEvent {
+ const rawItems = Array.isArray(args.items) ? args.items : [];
+ const items = rawItems
+ .map((item, index): AskInputItem | null => {
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
+ const row = item as Record;
+ const id =
+ cleanAskInputString(row.id) ||
+ `${row.kind === "documents" ? "documents" : "choice"}-${index + 1}`;
+ const responsePrefix = cleanAskInputString(row.response_prefix);
+
+ if (row.kind === "documents") {
+ const rawDocumentTypes = Array.isArray(row.document_types)
+ ? row.document_types
+ : [];
+ const documentTypes = rawDocumentTypes
+ .filter((type): type is string => typeof type === "string")
+ .map((type) => type.trim())
+ .filter(Boolean)
+ .map((type) => type.slice(0, 300))
+ .slice(0, 8);
+ return {
+ id: id.slice(0, 80),
+ kind: "documents",
+ document_types: documentTypes,
+ ...(responsePrefix
+ ? { response_prefix: responsePrefix.slice(0, 200) }
+ : {}),
+ };
+ }
+
+ const question = cleanAskInputString(
+ row.question,
+ "Please choose an option.",
+ );
+ const rawOptions = Array.isArray(row.options) ? row.options : [];
+ const options = rawOptions
+ .map((option): AskInputOption | null => {
+ if (!option || typeof option !== "object") return null;
+ const optionRow = option as Record;
+ const value =
+ cleanAskInputString(optionRow.value) ||
+ cleanAskInputString(optionRow.label);
+ if (!value) return null;
+ return {
+ value: value.slice(0, 500),
+ };
+ })
+ .filter((option): option is AskInputOption => !!option)
+ .slice(0, 8);
+ const normalizedOptions =
+ options.length > 0 ? options : [{ value: "Continue" }];
+ const otherLabel = cleanAskInputString(row.other_label, "Other");
+ return {
+ id: id.slice(0, 80),
+ kind: "choice",
+ question: question.slice(0, 500),
+ options: normalizedOptions,
+ allow_other: row.allow_other !== false,
+ other_label: otherLabel.slice(0, 80),
+ ...(responsePrefix
+ ? { response_prefix: responsePrefix.slice(0, 200) }
+ : {}),
+ };
+ })
+ .filter((item): item is AskInputItem => !!item)
+ .slice(0, 12);
+
+ return { type: "ask_inputs", items };
+}
+
+function upsertCourtlistenerCases(
+ state: CourtlistenerTurnState,
+ inputs: CourtlistenerCaseInput[],
+): CourtlistenerCaseRecord[] {
+ const records: CourtlistenerCaseRecord[] = [];
+ for (const input of inputs) {
+ if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) {
+ continue;
+ }
+ const clusterId = Math.floor(input.clusterId);
+ const current =
+ state.casesByClusterId.get(clusterId) ??
+ {
+ clusterId,
+ caseName: null,
+ citations: [],
+ url: null,
+ pdfUrl: null,
+ dateFiled: null,
+ };
+ const nextCitations = [
+ ...current.citations,
+ ...(input.citation ? [input.citation] : []),
+ ...(input.citations ?? []),
+ ]
+ .map(nonEmpty)
+ .filter((value): value is string => !!value);
+ const record: CourtlistenerCaseRecord = {
+ ...current,
+ caseName: current.caseName ?? nonEmpty(input.caseName),
+ citations: Array.from(new Set(nextCitations)),
+ url: current.url ?? nonEmpty(input.url),
+ pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl),
+ dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled),
+ opinions: current.opinions ?? input.opinions,
+ };
+ state.casesByClusterId.set(clusterId, record);
+ records.push(record);
+ }
+ return records;
+}
+
+function caseCitationEventFromRecord(
+ record: CourtlistenerCaseRecord,
+): CaseCitationEvent | null {
+ if (!record.url) return null;
+ return {
+ type: "case_citation",
+ cluster_id: record.clusterId,
+ case_name: record.caseName,
+ citation: record.citations[0] ?? null,
+ url: record.url,
+ pdfUrl: record.pdfUrl,
+ dateFiled: record.dateFiled,
+ };
+}
+
+function recordFromUnknown(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function stringField(
+ record: Record | null,
+ key: string,
+): string | null {
+ const value = record?.[key];
+ return typeof value === "string" ? value : null;
+}
+
+function numberField(
+ record: Record | null,
+ key: string,
+): number | null {
+ const value = record?.[key];
+ return typeof value === "number" && Number.isFinite(value)
+ ? Math.floor(value)
+ : null;
+}
+
+function stringArrayField(
+ record: Record | null,
+ key: string,
+): string[] {
+ const value = record?.[key];
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : [];
+}
+
+function courtlistenerCaseInputFromFetchedCase(
+ fallbackClusterId: number,
+ fetchedCase: unknown,
+): CourtlistenerCaseInput {
+ const record = recordFromUnknown(fetchedCase);
+ const clusterId =
+ numberField(record, "clusterId") ?? numberField(record, "id") ?? fallbackClusterId;
+ return {
+ clusterId,
+ caseName: stringField(record, "caseName"),
+ citations: stringArrayField(record, "citations"),
+ url: stringField(record, "url"),
+ pdfUrl: stringField(record, "pdfUrl"),
+ dateFiled: stringField(record, "dateFiled"),
+ opinions: Array.isArray(record?.opinions) ? record.opinions : undefined,
+ };
+}
+
+function courtlistenerOpinionCount(fetchedCase: unknown): number {
+ const record = recordFromUnknown(fetchedCase);
+ return Array.isArray(record?.opinions) ? record.opinions.length : 0;
+}
+
+function courtlistenerOpinionMetadata(raw: unknown) {
+ const opinion = recordFromUnknown(raw);
+ if (!opinion) return null;
+ const text =
+ stringField(opinion, "text") ??
+ (stringField(opinion, "html")
+ ? stripCaseOpinionHtml(stringField(opinion, "html")!)
+ : null);
+ return {
+ opinion_id:
+ numberField(opinion, "opinionId") ?? numberField(opinion, "id"),
+ type: stringField(opinion, "type"),
+ author: stringField(opinion, "author"),
+ per_curiam: stringField(opinion, "per_curiam"),
+ joined_by_str: stringField(opinion, "joined_by_str"),
+ url: stringField(opinion, "url"),
+ char_count: text?.length ?? 0,
+ };
+}
+
+function courtlistenerFetchedCaseMetadata(
+ record: CourtlistenerCaseRecord,
+ opinionCount: number,
+) {
+ return {
+ cluster_id: record.clusterId,
+ case_name: record.caseName,
+ citation: record.citations[0] ?? null,
+ citations: record.citations,
+ dateFiled: record.dateFiled,
+ url: record.url,
+ pdfUrl: record.pdfUrl,
+ opinion_count: opinionCount,
+ opinions: (record.opinions ?? [])
+ .map(courtlistenerOpinionMetadata)
+ .filter((opinion): opinion is NonNullable => !!opinion),
+ };
+}
+
+function stripCaseOpinionHtml(value: string): string {
+ return value
+ .replace(/
@@ -84,12 +62,14 @@ export default function GlobalError({
We encountered an unexpected error. This has been logged
and our team will look into it.
-
+