mike/backend/src/lib/chatTools.ts

4590 lines
149 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import path from "path";
import {
downloadFile,
generatedDocKey,
storageKey,
uploadFile,
} from "./storage";
import { convertedPdfKey } from "./convert";
import { createServerSupabase } from "./supabase";
import {
applyTrackedEdits,
extractDocxBodyText,
type EditInput,
} from "./docxTrackedChanges";
import { buildDownloadUrl } from "./downloadTokens";
import {
attachActiveVersionPaths,
loadActiveVersion,
} from "./documentVersions";
import {
getCourtlistenerCaseOpinions,
getCourtlistenerCases,
searchCourtlistenerCaseLaw,
verifyCourtlistenerCitations,
} from "./courtlistener";
import {
COURTLISTENER_SYSTEM_PROMPT,
COURTLISTENER_TOOL_NAMES,
COURTLISTENER_TOOLS,
type CaseCitationEvent,
type CourtlistenerToolEvent,
} from "./legalSourcesTools/courtlistenerTools";
import {
buildUserMcpTools,
executeMcpToolCall,
type McpToolEvent,
} from "./mcpConnectors";
import {
streamChatWithTools,
resolveModel,
DEFAULT_MAIN_MODEL,
type LlmMessage,
type OpenAIToolSchema,
} from "./llm";
import { safeErrorMessage } from "./safeError";
const STANDARD_FONT_DATA_URL = (() => {
try {
const pkgPath = require.resolve("pdfjs-dist/package.json");
return path.join(path.dirname(pkgPath), "standard_fonts") + path.sep;
} catch {
return undefined;
}
})();
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type DocStore = Map<
string,
{ storage_path: string; file_type: string; filename: string }
>;
export type WorkflowStore = Map<string, { title: string; prompt_md: string }>;
export type DocIndex = Record<
string,
{
document_id: string;
filename: string;
version_id?: string | null;
version_number?: number | null;
}
>;
export type TabularCellStore = {
columns: { index: number; name: string }[];
documents: { id: string; filename: string }[];
/** key: `${colIndex}:${docId}` */
cells: Map<
string,
{ summary: string; flag?: string; reasoning?: string } | null
>;
};
export type ToolCall = {
id: string;
function: { name: string; arguments: string };
};
export type ChatMessage = {
role: string;
content: string | null;
files?: { filename: string; document_id?: string }[];
workflow?: { id: string; title: string };
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
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.
- If the user selects a workflow with [Workflow: <title> (id: <id>)], immediately call read_workflow with that id and follow the workflow before doing anything else.
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. The marker number is the citation "ref" value, not a page, footnote, section, or document number.
At the very end of the response, append:
<CITATIONS>
[
{"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"}]}
]
</CITATIONS>
Citation rules:
- Every [N] marker must have exactly one matching entry with "ref": N.
- "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.
- 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 <CITATIONS> 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 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:
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. Gated per-user by the Legal Research > US feature toggle.
*/
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);
export const PROJECT_EXTRA_TOOLS = [
{
type: "function",
function: {
name: "list_documents",
description:
"List all documents available in the project. Returns each document's ID, filename, and file type. Call this to discover what documents are available before deciding which ones to read.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "fetch_documents",
description:
"Read the full text content of multiple documents in a single call. Use this instead of calling read_document repeatedly when you need to read several documents at once.",
parameters: {
type: "object",
properties: {
doc_ids: {
type: "array",
items: { type: "string" },
description:
"Array of document IDs to read (e.g. ['doc-0', 'doc-2'])",
},
},
required: ["doc_ids"],
},
},
},
{
type: "function",
function: {
name: "replicate_document",
description:
"Make byte-for-byte copies of an existing project document as new project documents. Use when the user wants standalone copies to edit (e.g. 'use this NDA as a template', 'give me three drafts I can adapt') without modifying the original. Pass `count` to create multiple copies in a single call rather than calling the tool repeatedly. Returns the new doc_id slugs so you can immediately call edit_document / read_document on them.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "ID of the source document to copy (e.g. 'doc-0').",
},
count: {
type: "integer",
description:
"How many copies to create. Defaults to 1. Maximum 20.",
minimum: 1,
maximum: 20,
},
new_filename: {
type: "string",
description:
"Optional base filename. With count > 1, copies are suffixed (e.g. 'Foo (1).docx', 'Foo (2).docx'). Extension is forced to match the source.",
},
},
required: ["doc_id"],
},
},
},
];
export const TABULAR_TOOLS = [
{
type: "function",
function: {
name: "read_table_cells",
description:
"Read the extracted cell content from the tabular review. Each cell contains the value extracted for a specific column from a specific document. Pass col_indices and/or row_indices (0-based) to read a subset; omit either to read all columns or all rows.",
parameters: {
type: "object",
properties: {
col_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based column indices to read (e.g. [0, 2]). Omit to read all columns.",
},
row_indices: {
type: "array",
items: { type: "integer" },
description:
"0-based document (row) indices to read (e.g. [0, 1]). Omit to read all rows.",
},
},
},
},
},
];
export const WORKFLOW_TOOLS = [
{
type: "function",
function: {
name: "list_workflows",
description:
"List all workflows available to the user. Returns each workflow's ID and title. Call this when the user asks to run a workflow, apply a template, or you need to discover what workflows exist.",
parameters: { type: "object", properties: {} },
},
},
{
type: "function",
function: {
name: "read_workflow",
description:
"Read the full instructions (prompt) of a workflow by its ID. Call this after list_workflows to load a specific workflow's prompt, then follow those instructions.",
parameters: {
type: "object",
properties: {
workflow_id: {
type: "string",
description: "The workflow ID to read",
},
},
required: ["workflow_id"],
},
},
},
];
export const TOOLS = [
{
type: "function",
function: {
name: "read_document",
description:
"Read the full text content of a document attached by the user. Always call this before answering questions about, summarising, or citing from a document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to read (e.g. 'doc-0', 'doc-1')",
},
},
required: ["doc_id"],
},
},
},
{
type: "function",
function: {
name: "find_in_document",
description:
"Search for specific strings inside a document — a Ctrl+F equivalent. Returns each match with surrounding context so you can locate and quote the exact text without reading the whole document. Matching is case-insensitive and whitespace-tolerant. Use this for targeted lookups (e.g. finding a clause title, party name, or a specific phrase) rather than reading the whole document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "The document ID to search (e.g. 'doc-0').",
},
query: {
type: "string",
description:
"The string to search for. Matching is case-insensitive and collapses runs of whitespace, so 'Section 4.2' matches 'section 4.2'.",
},
max_results: {
type: "integer",
description:
"Maximum number of matches to return (default 20). Use a smaller value for common terms.",
},
context_chars: {
type: "integer",
description:
"Characters of surrounding context to include on each side of a match (default 80).",
},
},
required: ["doc_id", "query"],
},
},
},
{
type: "function",
function: {
name: "generate_docx",
description:
"Generate a Word (.docx) document from structured content. Use this when the user asks you to draft, create, or produce a legal document. Returns a download URL for the generated file.",
parameters: {
type: "object",
properties: {
title: {
type: "string",
description: "Document title (used as filename and heading)",
},
landscape: {
type: "boolean",
description:
"Set to true for landscape page orientation. Default is portrait.",
},
sections: {
type: "array",
description:
"List of document sections. Each section may contain a heading, prose content, or a table.",
items: {
type: "object",
properties: {
heading: {
type: "string",
description: "Optional section heading",
},
level: {
type: "integer",
description: "Heading level: 1, 2, or 3",
},
content: {
type: "string",
description:
"Prose text content (paragraphs separated by double newlines)",
},
pageBreak: {
type: "boolean",
description:
"Set to true to start this section on a new page. Use for contract signature pages.",
},
table: {
type: "object",
description: "Optional table to render in this section",
properties: {
headers: {
type: "array",
items: { type: "string" },
description: "Column header labels",
},
rows: {
type: "array",
items: {
type: "array",
items: { type: "string" },
},
description:
"Array of rows, each row is an array of cell strings matching the headers order",
},
},
required: ["headers", "rows"],
},
},
},
},
},
required: ["title", "sections"],
},
},
},
{
type: "function",
function: {
name: "edit_document",
description:
"Propose edits to a user-attached .docx as tracked changes. Each edit is a precise, minimal substitution of specific words/characters, NOT a whole-line or paragraph replacement. Use read_document first. Anchor each edit with short before/after context so it can be located unambiguously. Returns per-edit annotations the UI will render as Accept/Reject cards and a download link to the edited document.",
parameters: {
type: "object",
properties: {
doc_id: {
type: "string",
description: "Document slug (e.g. 'doc-0').",
},
edits: {
type: "array",
description: "List of precise substitutions.",
items: {
type: "object",
properties: {
find: {
type: "string",
description:
"Exact substring to replace (keep it as short as possible — ideally just the words/chars being changed).",
},
replace: {
type: "string",
description:
"Replacement text. Empty string = pure deletion.",
},
context_before: {
type: "string",
description:
"~40 chars immediately preceding `find`, used to disambiguate.",
},
context_after: {
type: "string",
description: "~40 chars immediately following `find`.",
},
reason: {
type: "string",
description:
"Short explanation shown to the user on the card.",
},
},
required: ["find", "replace", "context_before", "context_after"],
},
},
},
required: ["doc_id", "edits"],
},
},
},
];
type ParsedDocumentCitation = {
kind: "document";
ref: number;
doc_id: string;
page: number | string;
quote: string;
quotes: {
page: number | string;
quote: string;
}[];
};
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<string, unknown>;
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 });
}
return {
kind: "document",
ref,
doc_id: c.doc_id,
page: quotes[0].page,
quote: quotes[0].quote,
quotes,
};
}
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<string, unknown>) {
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<string, unknown>;
const text = typeof row.quote === "string" ? row.quote : row.text;
if (typeof text !== "string" || !text.trim()) return null;
return {
page: normalizeCitationPage(row.page ?? c.page),
quote: text,
};
})
.filter(
(quote): quote is { page: number | string; quote: string } => !!quote,
);
}
function normalizeCaseCitationQuotes(c: Record<string, unknown>) {
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<string, unknown>;
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,
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
export function resolveDoc(rawId: string, docIndex: DocIndex) {
return docIndex[rawId];
}
/**
* Resolve whatever identifier the model passed (`doc-N` slug, filename, or
* document UUID) back to a chat-local doc label. Generated docs surface in
* tool results with both `doc_id` (slug) and `document_id` (UUID), so the
* model often picks the wrong one — without this fallback `read_document`
* silently returns "not found" and the model gives up and re-generates.
*/
export function resolveDocLabel(
rawId: string,
docStore: DocStore,
docIndex?: DocIndex,
): string | null {
if (docStore.has(rawId)) return rawId;
for (const [label, info] of docStore.entries()) {
if (info.filename === rawId) return label;
}
if (docIndex) {
for (const [label, info] of Object.entries(docIndex)) {
if (info.document_id === rawId) return label;
}
}
return null;
}
function citationReminder(docLabel: string, filename: string): string {
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 <CITATIONS> JSON block.`,
`Every citation entry for this document MUST use "doc_id": "${docLabel}".`,
`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.`,
`Do not use "marker" or "text" keys in the citation block; use "ref" and "quotes".`,
].join("\n");
}
/**
* Append a tool-activity summary to the most recent assistant message so
* the model can see what it just did (read / create / edit / workflow
* applied) in the prior turn — otherwise it only sees its own prose and
* forgets which docs it touched, which leads to e.g. re-generating a doc
* that already exists.
*
* Doc references use the *current-turn* `doc_id` slug (looked up by
* matching the event's stored `document_id` against this turn's freshly
* built `docIndex`), since slugs are reassigned every turn and the old
* slug from the prior turn would be meaningless. Falls back to filename
* only if the doc is no longer in the index (deleted, scope changed).
*/
export async function enrichWithPriorEvents(
messages: ChatMessage[],
chatId: string | null | undefined,
db: ReturnType<typeof createServerSupabase>,
docIndex: DocIndex,
): Promise<ChatMessage[]> {
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<string, string>();
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<string, unknown>[]) {
if (ev?.type === "doc_created") {
lines.push(`- generate_docx → ${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}"`);
}
}
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) at the start of every response that involves a document's content, even if you have read it in a previous turn. Failure to do so 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<string, string>();
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 async function extractPdfText(buf: ArrayBuffer): Promise<string> {
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<typeof createServerSupabase>,
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<typeof Paragraph> | InstanceType<typeof Table>;
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 colCount = headers.length;
const tableRows: InstanceType<typeof TableRow>[] = [];
// 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) };
}
}
// ---------------------------------------------------------------------------
// 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<typeof createServerSupabase>,
): 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<typeof createServerSupabase>;
/**
* 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
// ---------------------------------------------------------------------------
async function readDocumentContent(
docLabel: string,
docStore: DocStore,
write: (s: string) => void,
docIndex?: DocIndex,
db?: ReturnType<typeof createServerSupabase>,
opts?: { emitEvents?: boolean },
): Promise<string> {
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;
if (docInfo.file_type === "pdf") {
text = await extractPdfText(raw);
devLog(
`[read_document] pdf extracted length=${text.length} for filename="${docInfo.filename}"`,
);
} else if (docInfo.file_type === "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 {
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();
}
type TextMatch = {
index: number;
excerpt: string;
context: string;
};
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.
*/
async function findInDocumentContent(params: {
docLabel: string;
query: string;
maxResults?: number;
contextChars?: number;
docStore: DocStore;
write: (s: string) => void;
docIndex?: DocIndex;
db?: ReturnType<typeof createServerSupabase>;
}): Promise<string> {
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 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;
}[];
};
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[];
};
type CourtlistenerTurnState = {
casesByClusterId: Map<number, CourtlistenerCaseRecord>;
};
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
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<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function stringField(
record: Record<string, unknown> | null,
key: string,
): string | null {
const value = record?.[key];
return typeof value === "string" ? value : null;
}
function numberField(
record: Record<string, unknown> | null,
key: string,
): number | null {
const value = record?.[key];
return typeof value === "number" && Number.isFinite(value)
? Math.floor(value)
: null;
}
function stringArrayField(
record: Record<string, unknown> | 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<typeof opinion> => !!opinion),
};
}
function stripCaseOpinionHtml(value: string): string {
return value
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
type CachedCaseOpinionText = {
opinion_id: number | null;
type: string | null;
author: string | null;
url: string | null;
text: string;
};
function cachedCaseOpinionTexts(
record: CourtlistenerCaseRecord,
): CachedCaseOpinionText[] {
return (record.opinions ?? [])
.map((raw) => {
const opinion = recordFromUnknown(raw);
if (!opinion) return null;
const text =
stringField(opinion, "text") ??
(stringField(opinion, "html")
? stripCaseOpinionHtml(stringField(opinion, "html")!)
: null);
if (!text) return null;
return {
opinion_id:
numberField(opinion, "opinionId") ?? numberField(opinion, "id"),
type: stringField(opinion, "type"),
author: stringField(opinion, "author"),
url: stringField(opinion, "url"),
text,
};
})
.filter((opinion): opinion is CachedCaseOpinionText => !!opinion);
}
function requestedCourtlistenerOpinionIds(args: Record<string, unknown>) {
const rawIds = Array.isArray(args.opinionIds)
? args.opinionIds
: Array.isArray(args.opinion_ids)
? args.opinion_ids
: typeof args.opinionId === "number"
? [args.opinionId]
: typeof args.opinion_id === "number"
? [args.opinion_id]
: [];
return Array.from(
new Set(
rawIds
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.floor(value)),
),
);
}
type FindInCaseArgs = {
clusterId: number | null;
query: string;
maxResults: number;
contextChars: number;
};
function parseFindInCaseArgs(args: Record<string, unknown>): FindInCaseArgs {
return {
clusterId:
typeof args.clusterId === "number" && Number.isFinite(args.clusterId)
? Math.floor(args.clusterId)
: typeof args.cluster_id === "number" && Number.isFinite(args.cluster_id)
? Math.floor(args.cluster_id)
: null,
query: typeof args.query === "string" ? args.query : "",
maxResults:
typeof args.max_results === "number"
? Math.max(0, Math.floor(args.max_results))
: 20,
contextChars:
typeof args.context_chars === "number"
? Math.max(0, Math.floor(args.context_chars))
: 160,
};
}
function findInCaseSearchSummary(
event: Extract<CourtlistenerToolEvent, { type: "courtlistener_find_in_case" }>,
) {
return {
cluster_id: event.cluster_id,
query: event.query,
total_matches: event.total_matches,
case_name: event.case_name,
citation: event.citation,
error: event.error,
};
}
function cachedCaseNotFetchedResult(clusterId: number | null) {
return {
ok: false,
cluster_id: clusterId,
error:
"Case has not been fetched in this turn. Call courtlistener_get_cases first.",
};
}
export async function runToolCalls(
toolCalls: ToolCall[],
docStore: DocStore,
userId: string,
db: ReturnType<typeof createServerSupabase>,
write: (s: string) => void,
workflowStore?: WorkflowStore,
tabularStore?: TabularCellStore,
docIndex?: DocIndex,
turnEditState?: TurnEditState,
projectId?: string | null,
courtlistenerState?: CourtlistenerTurnState,
apiKeys?: import("./llm").UserApiKeys,
): Promise<{
toolResults: unknown[];
docsRead: { filename: string; document_id?: string }[];
docsFound: { filename: string; query: string; total_matches: number }[];
docsCreated: DocCreatedResult[];
docsReplicated: DocReplicatedResult[];
workflowsApplied: { workflow_id: string; title: string }[];
docsEdited: DocEditedResult[];
courtlistenerEvents: CourtlistenerToolEvent[];
caseCitationEvents: CaseCitationEvent[];
mcpEvents: McpToolEvent[];
}> {
const toolResults: unknown[] = [];
const docsRead: { filename: string; document_id?: string }[] = [];
const docsFound: {
filename: string;
query: string;
total_matches: number;
}[] = [];
const docsCreated: DocCreatedResult[] = [];
const docsReplicated: DocReplicatedResult[] = [];
const workflowsApplied: { workflow_id: string; title: string }[] = [];
const docsEdited: DocEditedResult[] = [];
const courtlistenerEvents: CourtlistenerToolEvent[] = [];
const caseCitationEvents: CaseCitationEvent[] = [];
const mcpEvents: McpToolEvent[] = [];
const courtState: CourtlistenerTurnState =
courtlistenerState ??
{
casesByClusterId: new Map(),
};
const groupedFindInCaseSearches = toolCalls
.filter((tc) => tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase)
.map((tc) => {
let rawArgs: Record<string, unknown> = {};
try {
rawArgs = JSON.parse(tc.function.arguments || "{}");
} catch {
/* ignore */
}
const parsed = parseFindInCaseArgs(rawArgs);
return {
cluster_id: parsed.clusterId,
query: parsed.query,
total_matches: 0,
};
});
const shouldGroupFindInCase = groupedFindInCaseSearches.length > 1;
let groupedFindInCaseStarted = false;
const groupedFindInCaseEvents: Extract<
CourtlistenerToolEvent,
{ type: "courtlistener_find_in_case" }
>[] = [];
for (const tc of toolCalls) {
let args: Record<string, unknown> = {};
try {
args = JSON.parse(tc.function.arguments || "{}");
} catch {
/* ignore */
}
if (tc.function.name.startsWith("mcp_")) {
write(
`data: ${JSON.stringify({
type: "mcp_tool_start",
name: tc.function.name,
})}\n\n`,
);
const { content, event } = await executeMcpToolCall(
userId,
tc.function.name,
args,
db,
);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content,
});
mcpEvents.push(event);
write(
`data: ${JSON.stringify({
type: "mcp_tool_result",
name: tc.function.name,
connector_name: event.connector_name,
tool_name: event.tool_name,
status: event.status,
error: event.error,
})}\n\n`,
);
continue;
}
if (tc.function.name === "read_document") {
const rawDocId = args.doc_id as string;
const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
const content = await readDocumentContent(
docId,
docStore,
write,
docIndex,
db,
);
const filename = docStore.get(docId)?.filename;
const documentId = docIndex?.[docId]?.document_id;
if (filename) docsRead.push({ filename, document_id: documentId });
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: filename
? `${citationReminder(docId, filename)}\n\n${content}`
: content,
});
} else if (tc.function.name === "find_in_document") {
const rawDocId = args.doc_id as string;
const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
const query = (args.query as string) ?? "";
const maxResults =
typeof args.max_results === "number" ? args.max_results : undefined;
const contextChars =
typeof args.context_chars === "number" ? args.context_chars : undefined;
const content = await findInDocumentContent({
docLabel: docId,
query,
maxResults,
contextChars,
docStore,
write,
docIndex,
db,
});
const filename = docStore.get(docId)?.filename;
if (filename) {
let totalMatches = 0;
try {
const parsed = JSON.parse(content) as {
total_matches?: number;
};
totalMatches = parsed.total_matches ?? 0;
} catch {
/* ignore — still record the find attempt */
}
docsFound.push({
filename,
query,
total_matches: totalMatches,
});
}
toolResults.push({ role: "tool", tool_call_id: tc.id, content });
} else if (tc.function.name === "list_documents") {
const list = Array.from(docStore.entries()).map(([doc_id, info]) => ({
doc_id,
filename: info.filename,
file_type: info.file_type,
}));
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(list),
});
} else if (tc.function.name === "fetch_documents") {
const rawDocIds = (args.doc_ids as string[]) ?? [];
const docIds = rawDocIds.map(
(id) => resolveDocLabel(id, docStore, docIndex) ?? id,
);
const parts: string[] = [];
for (const docId of docIds) {
const content = await readDocumentContent(
docId,
docStore,
write,
docIndex,
db,
);
const filename = docStore.get(docId)?.filename ?? docId;
parts.push(
`--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`,
);
if (docStore.get(docId)) {
const documentId = docIndex?.[docId]?.document_id;
docsRead.push({ filename, document_id: documentId });
}
}
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: parts.join("\n\n"),
});
} else if (tc.function.name === "list_workflows") {
const list = workflowStore
? Array.from(workflowStore.entries()).map(([id, w]) => ({
id,
title: w.title,
}))
: [];
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(list),
});
} else if (tc.function.name === "read_workflow") {
const wfId = args.workflow_id as string;
const wf = workflowStore?.get(wfId);
if (wf) {
write(
`data: ${JSON.stringify({ type: "workflow_applied", workflow_id: wfId, title: wf.title })}\n\n`,
);
workflowsApplied.push({ workflow_id: wfId, title: wf.title });
}
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: wf ? wf.prompt_md : `Workflow '${wfId}' not found.`,
});
} else if (tc.function.name === "read_table_cells" && tabularStore) {
const colIndices = args.col_indices as number[] | undefined;
const rowIndices = args.row_indices as number[] | undefined;
const filteredCols = colIndices?.length
? tabularStore.columns.filter((_, i) => colIndices.includes(i))
: tabularStore.columns;
const filteredDocs = rowIndices?.length
? tabularStore.documents.filter((_, i) => rowIndices.includes(i))
: tabularStore.documents;
const label = `${filteredCols.length} ${filteredCols.length === 1 ? "column" : "columns"} × ${filteredDocs.length} ${filteredDocs.length === 1 ? "row" : "rows"}`;
write(
`data: ${JSON.stringify({ type: "doc_read_start", filename: label })}\n\n`,
);
const lines: string[] = [];
for (const col of filteredCols) {
const colPos = tabularStore.columns.findIndex(
(c) => c.index === col.index,
);
for (const doc of filteredDocs) {
const rowPos = tabularStore.documents.findIndex(
(d) => d.id === doc.id,
);
const cell = tabularStore.cells.get(`${col.index}:${doc.id}`);
lines.push(
`[COL:${colPos} "${col.name}" | ROW:${rowPos} "${doc.filename}"]`,
);
if (cell?.summary) {
lines.push(`Summary: ${cell.summary}`);
if (cell.flag) lines.push(`Flag: ${cell.flag}`);
if (cell.reasoning) lines.push(`Reasoning: ${cell.reasoning}`);
} else {
lines.push(`(not yet generated)`);
}
lines.push("");
}
}
write(
`data: ${JSON.stringify({ type: "doc_read", filename: label })}\n\n`,
);
docsRead.push({ filename: label });
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: lines.join("\n") || "No cells found.",
});
} else if (tc.function.name === COURTLISTENER_TOOL_NAMES.searchCaseLaw) {
const query = typeof args.query === "string" ? args.query : "";
write(
`data: ${JSON.stringify({ type: "courtlistener_search_case_law_start", query })}\n\n`,
);
try {
const result = await searchCourtlistenerCaseLaw({
query: query || undefined,
court: typeof args.court === "string" ? args.court : undefined,
filedAfter:
typeof args.filedAfter === "string" ? args.filedAfter : undefined,
filedBefore:
typeof args.filedBefore === "string" ? args.filedBefore : undefined,
limit: typeof args.limit === "number" ? args.limit : undefined,
apiToken: apiKeys?.courtlistener,
});
const resultCount =
result &&
typeof result === "object" &&
Array.isArray((result as { results?: unknown }).results)
? (result as { results: unknown[] }).results.length
: 0;
const error =
result &&
typeof result === "object" &&
typeof (result as { error?: unknown }).error === "string"
? (result as { error: string }).error
: undefined;
const event: CourtlistenerToolEvent = {
type: "courtlistener_search_case_law",
query,
result_count: resultCount,
...(error ? { error } : {}),
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(result),
});
} catch (err) {
const event: CourtlistenerToolEvent = {
type: "courtlistener_search_case_law",
query,
result_count: 0,
error:
err instanceof Error ? err.message : "CourtListener search failed.",
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
error:
err instanceof Error
? err.message
: "CourtListener search failed.",
}),
});
}
} else if (tc.function.name === COURTLISTENER_TOOL_NAMES.getCases) {
const rawClusterIds = Array.isArray(args.clusterIds)
? args.clusterIds
: Array.isArray(args.cluster_ids)
? args.cluster_ids
: typeof args.clusterId === "number"
? [args.clusterId]
: [];
const clusterIds = Array.from(
new Set(
rawClusterIds
.filter((value): value is number => typeof value === "number")
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.floor(value)),
),
);
write(
`data: ${JSON.stringify({ type: "courtlistener_get_cases_start", cluster_ids: clusterIds })}\n\n`,
);
try {
const result = await getCourtlistenerCases({
clusterIds,
db,
apiToken: apiKeys?.courtlistener,
});
const fetchedCases =
result &&
typeof result === "object" &&
Array.isArray((result as { cases?: unknown }).cases)
? (result as { cases: unknown[] }).cases
: [];
fetchedCases.forEach((fetchedCase, index) => {
const clusterId =
courtlistenerCaseInputFromFetchedCase(
clusterIds[index] ?? 0,
fetchedCase,
).clusterId ?? 0;
if (clusterId) {
write(
`data: ${JSON.stringify({ type: "case_opinions", cluster_id: clusterId, case: fetchedCase })}\n\n`,
);
}
});
const caseRecords = upsertCourtlistenerCases(
courtState,
fetchedCases.map((fetchedCase, index) =>
courtlistenerCaseInputFromFetchedCase(
clusterIds[index] ?? 0,
fetchedCase,
),
),
);
const opinionCount = fetchedCases.reduce<number>(
(sum, fetchedCase) => sum + courtlistenerOpinionCount(fetchedCase),
0,
);
const caseOpinionCountByClusterId = new Map<number, number>();
fetchedCases.forEach((fetchedCase, index) => {
const clusterId =
courtlistenerCaseInputFromFetchedCase(
clusterIds[index] ?? 0,
fetchedCase,
).clusterId ?? 0;
if (clusterId) {
caseOpinionCountByClusterId.set(
clusterId,
courtlistenerOpinionCount(fetchedCase),
);
}
});
const errors = fetchedCases
.map((fetchedCase) =>
stringField(recordFromUnknown(fetchedCase), "error"),
)
.filter((error): error is string => !!error);
const resultError =
result &&
typeof result === "object" &&
typeof (result as { error?: unknown }).error === "string"
? (result as { error: string }).error
: undefined;
const hasMultipleOpinionCase = caseRecords.some(
(record) =>
(caseOpinionCountByClusterId.get(record.clusterId) ?? 0) > 1,
);
const event: CourtlistenerToolEvent = {
type: "courtlistener_get_cases",
cluster_ids: clusterIds,
case_count: fetchedCases.length,
opinion_count: opinionCount,
cases: caseRecords.map((record) => ({
cluster_id: record.clusterId,
case_name: record.caseName,
citation: record.citations[0] ?? null,
dateFiled: record.dateFiled,
url: record.url,
})),
...(resultError || errors.length
? { error: resultError ?? errors.join("; ") }
: {}),
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: !resultError && errors.length === 0,
cluster_ids: clusterIds,
case_count: fetchedCases.length,
opinion_count: opinionCount,
cases: caseRecords.map((record) =>
courtlistenerFetchedCaseMetadata(
record,
caseOpinionCountByClusterId.get(record.clusterId) ?? 0,
),
),
...(resultError || errors.length
? { error: resultError ?? errors.join("; ") }
: {}),
next_required_action: hasMultipleOpinionCase
? "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages. At least one fetched case has multiple opinions; if snippets are insufficient, choose the needed opinion_id(s) from the text-free opinion metadata and call courtlistener_read_case with only those IDs. Do not read all opinions unless the question requires it."
: "Opinion text is cached server-side only. Use courtlistener_find_in_case with short 1-3 word keyword probes for relevant passages, or courtlistener_read_case if snippets are insufficient.",
}),
});
} catch (err) {
const event: CourtlistenerToolEvent = {
type: "courtlistener_get_cases",
cluster_ids: clusterIds,
case_count: 0,
opinion_count: 0,
error:
err instanceof Error
? err.message
: "CourtListener case fetch failed.",
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
error:
err instanceof Error
? err.message
: "CourtListener case fetch failed.",
}),
});
}
} else if (tc.function.name === COURTLISTENER_TOOL_NAMES.findInCase) {
const { clusterId, query, maxResults, contextChars } =
parseFindInCaseArgs(args);
if (shouldGroupFindInCase) {
if (!groupedFindInCaseStarted) {
write(
`data: ${JSON.stringify({
type: "courtlistener_find_in_case_start",
cluster_id: null,
query: "",
searches: groupedFindInCaseSearches,
})}\n\n`,
);
groupedFindInCaseStarted = true;
}
} else {
write(
`data: ${JSON.stringify({ type: "courtlistener_find_in_case_start", cluster_id: clusterId, query })}\n\n`,
);
}
const record =
typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined;
if (!record) {
const payload = cachedCaseNotFetchedResult(clusterId);
const event: CourtlistenerToolEvent = {
type: "courtlistener_find_in_case",
cluster_id: clusterId,
query,
total_matches: 0,
error: payload.error,
};
if (shouldGroupFindInCase) {
groupedFindInCaseEvents.push(event);
} else {
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
}
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(payload),
});
continue;
}
const opinions = cachedCaseOpinionTexts(record);
const hits: Array<
TextMatch & {
opinion_id: number | null;
type: string | null;
author: string | null;
url: string | null;
}
> = [];
let totalMatches = 0;
for (const opinion of opinions) {
const remaining = Math.max(0, maxResults - hits.length);
const result = findTextMatches({
text: opinion.text,
query,
maxResults: remaining,
contextChars,
startIndex: hits.length,
});
totalMatches += result.totalMatches;
hits.push(
...result.hits.map((hit) => ({
...hit,
opinion_id: opinion.opinion_id,
type: opinion.type,
author: opinion.author,
url: opinion.url,
})),
);
}
const event: CourtlistenerToolEvent = {
type: "courtlistener_find_in_case",
cluster_id: record.clusterId,
query,
total_matches: totalMatches,
case_name: record.caseName,
citation: record.citations[0] ?? null,
};
if (shouldGroupFindInCase) {
groupedFindInCaseEvents.push(event);
} else {
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
}
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: true,
cluster_id: record.clusterId,
case_name: record.caseName,
citation: record.citations[0] ?? null,
query,
total_matches: totalMatches,
returned: hits.length,
truncated: totalMatches > hits.length,
hits,
}),
});
} else if (tc.function.name === COURTLISTENER_TOOL_NAMES.readCase) {
const clusterId =
typeof args.clusterId === "number" && Number.isFinite(args.clusterId)
? Math.floor(args.clusterId)
: typeof args.cluster_id === "number" &&
Number.isFinite(args.cluster_id)
? Math.floor(args.cluster_id)
: null;
write(
`data: ${JSON.stringify({ type: "courtlistener_read_case_start", cluster_id: clusterId })}\n\n`,
);
const record =
typeof clusterId === "number" ? courtState.casesByClusterId.get(clusterId) : undefined;
if (!record) {
const payload = cachedCaseNotFetchedResult(clusterId);
const event: CourtlistenerToolEvent = {
type: "courtlistener_read_case",
cluster_id: clusterId,
opinion_count: 0,
error: payload.error,
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(payload),
});
continue;
}
const opinions = cachedCaseOpinionTexts(record);
const requestedOpinionIds = requestedCourtlistenerOpinionIds(args);
const selectedOpinions =
requestedOpinionIds.length > 0
? opinions.filter(
(opinion) =>
typeof opinion.opinion_id === "number" &&
requestedOpinionIds.includes(opinion.opinion_id),
)
: opinions.length === 1
? opinions
: [];
if (!selectedOpinions.length) {
const multipleOpinions = opinions.length > 1;
const payload = {
ok: false,
cluster_id: record.clusterId,
case_name: record.caseName,
citations: record.citations,
url: record.url,
dateFiled: record.dateFiled,
opinion_count: opinions.length,
opinions: (record.opinions ?? [])
.map(courtlistenerOpinionMetadata)
.filter(
(opinion): opinion is NonNullable<typeof opinion> =>
!!opinion,
),
error: multipleOpinions
? "Multiple opinions are available. Call courtlistener_read_case again with the opinionId or opinionIds needed."
: "No matching opinion_id was found for this fetched case.",
};
const event: CourtlistenerToolEvent = {
type: "courtlistener_read_case",
cluster_id: record.clusterId,
case_name: record.caseName,
citation: record.citations[0] ?? null,
opinion_count: 0,
error: payload.error,
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(payload),
});
continue;
}
const event: CourtlistenerToolEvent = {
type: "courtlistener_read_case",
cluster_id: record.clusterId,
case_name: record.caseName,
citation: record.citations[0] ?? null,
opinion_count: selectedOpinions.length,
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: true,
cluster_id: record.clusterId,
case_name: record.caseName,
citations: record.citations,
url: record.url,
dateFiled: record.dateFiled,
opinion_count: opinions.length,
returned_opinion_count: selectedOpinions.length,
opinions: selectedOpinions,
}),
});
} else if (tc.function.name === COURTLISTENER_TOOL_NAMES.verifyCitations) {
const citations = Array.isArray(args.citations)
? args.citations.filter(
(value): value is string => typeof value === "string",
)
: [];
const citationCount = citations.length;
write(
`data: ${JSON.stringify({ type: "courtlistener_verify_citations_start", citation_count: citationCount })}\n\n`,
);
try {
const result = (await verifyCourtlistenerCitations({
citations,
db,
apiToken: apiKeys?.courtlistener,
})) as {
citationLinks?: {
clusterId?: number | null;
citation?: string | null;
caseName?: string | null;
dateFiled?: string | null;
pdfUrl?: string | null;
url?: string | null;
markdown?: string;
}[];
results?: unknown[];
error?: string;
source?: string;
[key: string]: unknown;
};
if (Array.isArray(result.citationLinks)) {
const caseRecords = upsertCourtlistenerCases(
courtState,
result.citationLinks.map((link) => ({
clusterId: link.clusterId,
caseName: link.caseName,
citation: link.citation,
url: link.url,
pdfUrl: link.pdfUrl,
dateFiled: link.dateFiled,
})),
);
const recordsByClusterId = new Map(
caseRecords.map((record) => [record.clusterId, record]),
);
result.citationLinks = result.citationLinks.map((link) => {
if (!link.url) return link;
const href =
typeof link.clusterId === "number"
? `us-case-${link.clusterId}`
: link.url;
const label = [link.caseName, link.citation]
.filter(Boolean)
.join(", ");
const record =
typeof link.clusterId === "number"
? recordsByClusterId.get(link.clusterId)
: undefined;
if (record) {
const event = caseCitationEventFromRecord(record);
if (event) {
caseCitationEvents.push(event);
write(`data: ${JSON.stringify(event)}\n\n`);
}
}
return {
...link,
markdown: `[${label || link.url}](${href})`,
};
});
}
const rows =
result &&
typeof result === "object" &&
Array.isArray((result as { results?: unknown }).results)
? (result as { results: unknown[] }).results
: [];
const matchCount = rows.reduce<number>((count, row) => {
if (!row || typeof row !== "object") return count;
const clusters = (row as { clusters?: unknown }).clusters;
return count + (Array.isArray(clusters) ? clusters.length : 0);
}, 0);
const error =
result &&
typeof result === "object" &&
typeof (result as { error?: unknown }).error === "string"
? (result as { error: string }).error
: undefined;
const event: CourtlistenerToolEvent = {
type: "courtlistener_verify_citations",
citation_count: citationCount,
match_count: matchCount,
...(error ? { error } : {}),
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(result),
});
} catch (err) {
const event: CourtlistenerToolEvent = {
type: "courtlistener_verify_citations",
citation_count: citationCount,
match_count: 0,
error:
err instanceof Error
? err.message
: "CourtListener citation lookup failed.",
};
write(`data: ${JSON.stringify(event)}\n\n`);
courtlistenerEvents.push(event);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
error:
err instanceof Error
? err.message
: "CourtListener citation lookup failed.",
}),
});
}
} else if (tc.function.name === "edit_document" && docIndex) {
const rawDocId = args.doc_id as string;
const editsRaw = args.edits as unknown[] | undefined;
const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
const docInfo = docStore.get(docId);
const indexed = docIndex?.[docId];
const emitEditError = (
filename: string,
documentId: string,
error: string,
) => {
// Surface the failure as a failed "Edited" block in the UI
// (start → done-with-error) so it matches the shape the
// success/late-failure paths already use.
write(
`data: ${JSON.stringify({
type: "doc_edited_start",
filename,
})}\n\n`,
);
write(
`data: ${JSON.stringify({
type: "doc_edited",
filename,
document_id: documentId,
version_id: "",
download_url: "",
annotations: [],
error,
})}\n\n`,
);
};
if (!docInfo || !indexed) {
const err = `Document '${docId}' not found in this chat's attachments.`;
emitEditError(docId, indexed?.document_id ?? "", err);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({ error: err }),
});
} else if (!Array.isArray(editsRaw) || editsRaw.length === 0) {
const err = "edits array is required and must not be empty.";
emitEditError(docInfo.filename, indexed.document_id, err);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({ error: err }),
});
} else if (docInfo.file_type !== "docx") {
const err = "edit_document only supports .docx files.";
emitEditError(docInfo.filename, indexed.document_id, err);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({ error: err }),
});
} else {
write(
`data: ${JSON.stringify({
type: "doc_edited_start",
filename: docInfo.filename,
})}\n\n`,
);
const edits: EditInput[] = (editsRaw as Record<string, unknown>[]).map(
(e) => ({
find: String(e.find ?? ""),
replace: String(e.replace ?? ""),
context_before: String(e.context_before ?? ""),
context_after: String(e.context_after ?? ""),
reason: e.reason ? String(e.reason) : undefined,
}),
);
const reuseVersion = turnEditState?.get(indexed.document_id);
const result = await runEditDocument({
documentId: indexed.document_id,
userId,
edits,
db,
reuseVersion,
});
if (result.ok) {
turnEditState?.set(indexed.document_id, {
versionId: result.version_id,
versionNumber: result.version_number,
storagePath: result.storage_path,
});
// Keep the chat-local doc label pointed at the latest
// edited version so any follow-up read_document call in
// the same assistant turn reads and cites the same bytes.
if (docIndex[docId]) {
docIndex[docId] = {
...docIndex[docId],
version_id: result.version_id,
version_number: result.version_number,
};
}
const currentDocStore = docStore.get(docId);
if (currentDocStore) {
docStore.set(docId, {
...currentDocStore,
storage_path: result.storage_path,
});
}
const payload: DocEditedResult = {
filename: docInfo.filename,
document_id: indexed.document_id,
version_id: result.version_id,
version_number: result.version_number,
download_url: result.download_url,
annotations: result.annotations,
};
docsEdited.push(payload);
write(
`data: ${JSON.stringify({
type: "doc_edited",
...payload,
})}\n\n`,
);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: true,
doc_id: docId,
document_id: indexed.document_id,
version_id: result.version_id,
version_number: result.version_number,
applied: result.annotations.length,
errors: result.errors,
next_required_action: [
`The edited document remains available as doc_id "${docId}".`,
`Before making factual claims about the edited document's final contents, call read_document with doc_id "${docId}" and base the response on that returned text.`,
`Do not include download links or URLs in your prose response; the edited document card is shown automatically by the UI.`,
`If you describe specific content from the edited document, cite it with [N] markers and a final <CITATIONS> block using doc_id "${docId}".`,
].join(" "),
}),
});
} else {
write(
`data: ${JSON.stringify({
type: "doc_edited",
filename: docInfo.filename,
document_id: indexed.document_id,
version_id: "",
download_url: "",
annotations: [],
error: result.error,
})}\n\n`,
);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: false,
error: result.error,
}),
});
}
}
} else if (tc.function.name === "replicate_document" && docIndex) {
const rawDocId = args.doc_id as string;
const requestedFilename =
typeof args.new_filename === "string" && args.new_filename.trim()
? args.new_filename.trim()
: null;
const requestedCount =
typeof args.count === "number" && Number.isFinite(args.count)
? Math.max(1, Math.min(20, Math.floor(args.count)))
: 1;
const sourceLabel =
resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
const sourceInfo = docStore.get(sourceLabel);
const sourceIndexed = docIndex[sourceLabel];
const sourceFilename = sourceInfo?.filename ?? rawDocId;
write(
`data: ${JSON.stringify({
type: "doc_replicate_start",
filename: sourceFilename,
count: requestedCount,
})}\n\n`,
);
const fail = (error: string) => {
write(
`data: ${JSON.stringify({
type: "doc_replicated",
filename: sourceFilename,
count: requestedCount,
copies: [],
error,
})}\n\n`,
);
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({ ok: false, error }),
});
};
if (!sourceInfo || !sourceIndexed) {
fail(`Document '${rawDocId}' not found in this project.`);
} else if (!projectId) {
fail("replicate_document is only available in project chats.");
} else {
try {
// Pull the active version once — every copy gets the
// same starting bytes (with any accepted tracked
// changes rolled in), no point re-fetching per copy.
const active = await loadActiveVersion(sourceIndexed.document_id, db);
const sourcePath = active?.storage_path ?? sourceInfo.storage_path;
const sourcePdfPath = active?.pdf_storage_path ?? null;
const raw = await downloadFile(sourcePath);
const pdfBytes = sourcePdfPath
? await downloadFile(sourcePdfPath)
: null;
if (!raw) {
fail("Could not read the source document's bytes from storage.");
} else {
// Build N filenames. With count=1 keep the
// pre-existing "(copy)" suffix; with count>1 use
// numbered "(1)", "(2)" suffixes.
const srcExt = sourceInfo.filename.match(/\.[^./\\]+$/)?.[0] ?? "";
const baseStem = (() => {
if (requestedFilename) {
return requestedFilename.replace(/\.[^./\\]+$/, "");
}
return sourceInfo.filename.replace(/\.[^./\\]+$/, "");
})();
const filenames: string[] = [];
for (let n = 1; n <= requestedCount; n++) {
const suffix =
requestedCount === 1
? requestedFilename
? ""
: " (copy)"
: ` (${n})`;
filenames.push(`${baseStem}${suffix}${srcExt}`);
}
// Bulk insert N documents in one round-trip.
const docRows = filenames.map((fn) => ({
project_id: projectId,
user_id: userId,
status: "ready",
}));
const { data: insertedDocs, error: docErr } = await db
.from("documents")
.insert(docRows)
.select("id");
if (docErr || !insertedDocs || insertedDocs.length === 0) {
fail(
`Failed to record replicated documents: ${docErr?.message ?? "unknown"}`,
);
} else {
// Preserve the request order so each row pairs
// with the right filename. Supabase returns
// inserted rows in the same order as the
// payload.
const newDocs = (insertedDocs as { id: string }[]).map(
(doc, idx) => ({
...doc,
filename: filenames[idx] ?? "Untitled document.docx",
}),
);
const contentType =
sourceInfo.file_type === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
// Parallel uploads: the doc bytes (and PDF
// rendition if any) for every new copy.
const uploadJobs: Promise<unknown>[] = [];
const newKeys: string[] = [];
const newPdfKeys: (string | null)[] = [];
for (const d of newDocs) {
const key = storageKey(userId, d.id, d.filename);
newKeys.push(key);
uploadJobs.push(uploadFile(key, raw, contentType));
if (pdfBytes) {
const pdfKey = convertedPdfKey(userId, d.id);
newPdfKeys.push(pdfKey);
uploadJobs.push(
uploadFile(pdfKey, pdfBytes, "application/pdf"),
);
} else {
newPdfKeys.push(null);
}
}
await Promise.all(uploadJobs);
// Bulk insert N versions in one round-trip.
const versionRows = newDocs.map((d, idx) => ({
document_id: d.id,
storage_path: newKeys[idx],
pdf_storage_path: newPdfKeys[idx],
source: "upload",
version_number: 1,
filename: d.filename,
file_type: active?.file_type ?? sourceInfo.file_type,
size_bytes: active?.size_bytes ?? raw.byteLength,
page_count: active?.page_count ?? null,
}));
const { data: insertedVersions, error: verErr } = await db
.from("document_versions")
.insert(versionRows)
.select("id, document_id");
if (
verErr ||
!insertedVersions ||
insertedVersions.length !== newDocs.length
) {
fail(
`Failed to record replicated document versions: ${verErr?.message ?? "unknown"}`,
);
} else {
const versionByDocId = new Map<string, string>();
for (const v of insertedVersions as {
id: string;
document_id: string;
}[]) {
versionByDocId.set(v.document_id, v.id);
}
// current_version_id has to be a per-row
// value, so a single UPDATE statement
// can't cover all N. Fan out in parallel
// instead of sequential awaits.
await Promise.all(
newDocs.map((d) =>
db
.from("documents")
.update({
current_version_id: versionByDocId.get(d.id),
})
.eq("id", d.id),
),
);
// Register every copy under a fresh doc-N
// slug so the model can edit/read any of
// them in the same turn.
const existingLabels = new Set(Object.keys(docIndex));
let nextLabelIdx = 0;
const copies: {
new_filename: string;
document_id: string;
version_id: string;
}[] = [];
const toolPayloadCopies: {
doc_id: string;
document_id: string;
version_id: string;
filename: string;
download_url: string;
}[] = [];
for (let idx = 0; idx < newDocs.length; idx++) {
const d = newDocs[idx];
const newKey = newKeys[idx];
const versionId = versionByDocId.get(d.id);
if (!versionId) continue;
while (existingLabels.has(`doc-${nextLabelIdx}`))
nextLabelIdx++;
const slug = `doc-${nextLabelIdx}`;
existingLabels.add(slug);
docIndex[slug] = {
document_id: d.id,
filename: d.filename,
};
docStore.set(slug, {
storage_path: newKey,
file_type: sourceInfo.file_type,
filename: d.filename,
});
copies.push({
new_filename: d.filename,
document_id: d.id,
version_id: versionId,
});
toolPayloadCopies.push({
doc_id: slug,
document_id: d.id,
version_id: versionId,
filename: d.filename,
download_url: buildDownloadUrl(newKey, d.filename),
});
}
write(
`data: ${JSON.stringify({
type: "doc_replicated",
filename: sourceFilename,
count: copies.length,
copies,
})}\n\n`,
);
docsReplicated.push({
filename: sourceFilename,
count: copies.length,
copies,
});
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify({
ok: true,
count: copies.length,
copies: toolPayloadCopies,
}),
});
}
}
}
} catch (e) {
fail(`replicate_document failed: ${String(e)}`);
}
}
} else if (tc.function.name === "generate_docx") {
const title = args.title as string;
const landscape = !!args.landscape;
devLog(
`[generate_docx] title="${title}" landscape=${landscape} args.landscape=${args.landscape}`,
);
const previewFilename = `${
title
.replace(/[^a-zA-Z0-9 _-]/g, "")
.trim()
.slice(0, 64) || "document"
}.docx`;
write(
`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`,
);
const result = await generateDocx(
title,
args.sections as unknown[],
userId,
db,
{ landscape, projectId: projectId ?? null },
);
let newDocLabel: string | null = null;
if ("filename" in result && "download_url" in result) {
const dlFilename = result.filename as string;
const dlUrl = result.download_url as string;
const documentId = (result as { document_id?: string }).document_id;
const versionId = (result as { version_id?: string }).version_id;
const versionNumber =
(result as { version_number?: number }).version_number ?? null;
const storagePath = (result as { storage_path?: string }).storage_path;
// Register the generated doc in the chat context so
// edit_document (and read_document / find_in_document)
// can act on it within the same assistant turn. New label
// is the next free `doc-N` index. Subsequent turns pick
// it up via the normal attachment/project doc query.
if (documentId && storagePath && docIndex) {
const existingLabels = new Set(Object.keys(docIndex));
let i = 0;
while (existingLabels.has(`doc-${i}`)) i++;
newDocLabel = `doc-${i}`;
docIndex[newDocLabel] = {
document_id: documentId,
filename: dlFilename,
};
docStore.set(newDocLabel, {
storage_path: storagePath,
file_type: "docx",
filename: dlFilename,
});
}
write(
`data: ${JSON.stringify({
type: "doc_created",
filename: dlFilename,
download_url: dlUrl,
document_id: documentId,
version_id: versionId,
version_number: versionNumber,
})}\n\n`,
);
docsCreated.push({
filename: dlFilename,
download_url: dlUrl,
document_id: documentId,
version_id: versionId,
version_number: versionNumber,
});
} else {
write(
`data: ${JSON.stringify({ type: "doc_created", filename: previewFilename, download_url: "" })}\n\n`,
);
}
// Surface the chat-local doc label in the tool result so the
// model can pass it as `doc_id` to edit_document / read_document
// / find_in_document in the same turn. Without this the model
// only sees the DB UUID, which isn't valid as a doc_id anchor.
const { download_url, storage_path, ...safeToolResult } =
result as Record<string, unknown>;
const toolResultPayload = newDocLabel
? {
...safeToolResult,
doc_id: newDocLabel,
next_required_action: [
`Before writing your final response, call read_document with doc_id "${newDocLabel}".`,
`Base your description on the generated document's actual returned text, not on memory of what you intended to generate.`,
`Do not include download links, URLs, or markdown links to the document in your prose response; the document card is shown automatically by the UI.`,
`Give a concise description of the generated document and, if you make factual claims about its contents, cite it with [N] markers and a final <CITATIONS> block using doc_id "${newDocLabel}", not any source/template document.`,
].join(" "),
}
: safeToolResult;
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: JSON.stringify(toolResultPayload),
});
}
}
if (shouldGroupFindInCase && groupedFindInCaseEvents.length > 0) {
const errors = groupedFindInCaseEvents
.map((event) => event.error)
.filter((error): error is string => !!error);
const groupEvent: CourtlistenerToolEvent = {
type: "courtlistener_find_in_case",
cluster_id: null,
query: "",
total_matches: groupedFindInCaseEvents.reduce(
(sum, event) => sum + event.total_matches,
0,
),
searches: groupedFindInCaseEvents.map(findInCaseSearchSummary),
...(errors.length ? { error: errors.join("; ") } : {}),
};
write(`data: ${JSON.stringify(groupEvent)}\n\n`);
courtlistenerEvents.push(groupEvent);
}
return {
toolResults,
docsRead,
docsFound,
docsCreated,
docsReplicated,
workflowsApplied,
docsEdited,
courtlistenerEvents,
caseCitationEvents,
mcpEvents,
};
}
// ---------------------------------------------------------------------------
// Citation parsing
// ---------------------------------------------------------------------------
const CITATIONS_BLOCK_RE = /<CITATIONS>\s*([\s\S]*?)\s*<\/CITATIONS>/;
const CITATIONS_OPEN_TAG = "<CITATIONS>";
const CITATIONS_CLOSE_TAG = "</CITATIONS>";
type CitationParseDiagnostics = {
hasBlock: boolean;
rawLength: number;
error: string | null;
};
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),
},
};
}
}
function parseCitations(text: string): ParsedCitation[] {
return parseCitationsWithDiagnostics(text).citations;
}
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;
}
function createCitationAnnotation(
citation: ParsedCitation,
docIndex: DocIndex,
casesByClusterId?: CourtlistenerTurnState["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,
quotes: citation.quotes,
};
}
// ---------------------------------------------------------------------------
// LLM streaming loop
// ---------------------------------------------------------------------------
export type EditAnnotation = {
kind: "edit";
edit_id: string;
document_id: string;
version_id: string;
version_number?: number | null;
change_id: string;
del_w_id?: string;
ins_w_id?: string;
deleted_text: string;
inserted_text: string;
context_before: string;
context_after: string;
reason?: string;
status: "pending" | "accepted" | "rejected";
};
type AssistantEvent =
| { type: "reasoning"; text: string }
| { 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";
}
}
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<typeof createServerSupabase>;
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[];
annotations: 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();
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) =>
createCitationAnnotation(
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,
courtlistenerEvents,
caseCitationEvents,
mcpEvents,
} = await runToolCalls(
toolCalls,
docStore,
userId,
db,
write,
workflowStore,
tabularStore,
docIndex,
turnEditState,
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 event of courtlistenerEvents) {
events.push(event);
}
for (const event of mcpEvents) {
events.push(event);
}
for (const event of caseCitationEvents) {
events.push(event);
}
// 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<string, string>();
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 (isAbortError(err)) {
flushPartialTurn({ emit: false });
throw new AssistantStreamAbortError(fullText, events);
}
flushPartialTurn();
const message = safeErrorMessage(err, "Stream error");
events.push({ type: "error", message });
throw new AssistantStreamError(message, fullText, events);
}
flushText();
// Parse and emit citations from <CITATIONS> block
const { citations: parsedCitations, diagnostics: citationDiagnostics } =
parseCitationsWithDiagnostics(fullText);
const citations = buildCitations
? buildCitations(fullText)
: parsedCitations.map((c) =>
createCitationAnnotation(
c,
docIndex,
courtlistenerTurnState.casesByClusterId,
),
);
devLog("[chat/stream] final citation annotations", {
hasCitationsBlock: citationDiagnostics.hasBlock,
citationsBlockLength: citationDiagnostics.rawLength,
parseError: citationDiagnostics.error,
parsedCitationCount: parsedCitations.length,
emittedAnnotationCount: citations.length,
usedCustomCitationBuilder: !!buildCitations,
});
write(
`data: ${JSON.stringify({ type: "citations", status: "final", citations })}\n\n`,
);
write("data: [DONE]\n\n");
return { fullText, events, annotations: citations };
}
// ---------------------------------------------------------------------------
// Annotation extraction (for DB save)
// ---------------------------------------------------------------------------
export function extractAnnotations(
fullText: string,
docIndex: DocIndex,
_events?: ({ type: string } & Record<string, unknown>[]) | unknown[],
): unknown[] {
return parseCitations(fullText).map((c) =>
createCitationAnnotation(c, docIndex),
);
}
export function stripTransientAssistantEvents(events: AssistantEvent[]) {
return events.filter((event) => event.type !== "case_opinions");
}
export function appendCancelledAssistantEvent(events: AssistantEvent[]) {
return [...events, { type: "content" as const, text: "Cancelled by user." }];
}
export function buildCancelledAssistantMessage(args: {
fullText: string;
events: AssistantEvent[];
buildAnnotations: (fullText: string, events: AssistantEvent[]) => unknown[];
}) {
const events = appendCancelledAssistantEvent(
stripTransientAssistantEvents(args.events),
);
return {
events,
annotations: args.buildAnnotations(args.fullText, events),
};
}
// ---------------------------------------------------------------------------
// Document context builder (from message file attachments)
// ---------------------------------------------------------------------------
export async function buildDocContext(
messages: ChatMessage[],
userId: string,
db: ReturnType<typeof createServerSupabase>,
chatId?: string | null,
): Promise<{ docIndex: DocIndex; docStore: DocStore }> {
const docIndex: DocIndex = {};
const docStore: DocStore = new Map();
const documentIds = new Set<string>();
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<string, unknown>[]) {
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<typeof createServerSupabase>,
): Promise<{
docIndex: DocIndex;
docStore: DocStore;
folderPaths: Map<string, string>;
}> {
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<string, string>(); // 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<typeof createServerSupabase>,
): Promise<WorkflowStore> {
const { BUILTIN_WORKFLOWS } = await import("./builtinWorkflows");
const store: WorkflowStore = new Map();
const normalizedUserEmail = (userEmail ?? "").trim().toLowerCase();
// Seed built-ins first
for (const wf of BUILTIN_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;
}