mike/backend/src/lib/chat/tools/toolDispatcher.ts

1896 lines
63 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 {
getCourtlistenerCases,
searchCourtlistenerCaseLaw,
verifyCourtlistenerCitations,
} from "../../courtlistener";
import {
COURTLISTENER_TOOL_NAMES,
type CaseCitationEvent,
type CourtlistenerToolEvent,
} from "./courtlistenerTools";
import {
executeMcpToolCall,
type McpToolEvent,
} from "../../mcpConnectors";
import { createServerSupabase } from "../../supabase";
import {
type DocStore,
type DocIndex,
type TabularCellStore,
type WorkflowStore,
type ToolCall,
type AskInputItem,
type AskInputOption,
type AskInputsEvent,
devLog,
resolveDocLabel,
} from "../types";
import {
downloadFile,
storageKey,
uploadFile,
} from "../../storage";
import { convertedPdfKey } from "../../convert";
import { contentTypeForDocumentType } from "../../documentTypes";
import { buildDownloadUrl } from "../../downloadTokens";
import { loadActiveVersion } from "../../documentVersions";
import { type EditInput } from "../../docxTrackedChanges";
import {
citationReminder,
generateDocx,
generateExcel,
generatePpt,
getTurnReadIdentity,
duplicateReadDocumentResult,
clearTurnReadsForDocument,
readDocumentContent,
findInDocumentContent,
findTextMatches,
runEditDocument,
safeGeneratedFilename,
type DocEditedResult,
type TurnEditState,
type TurnReadState,
type DocCreatedResult,
type DocReplicatedResult,
type TextMatch,
} from "./documentOps";
type CourtlistenerCaseRecord = {
clusterId: number;
caseName: string | null;
citations: string[];
url: string | null;
pdfUrl: string | null;
dateFiled: string | null;
opinions?: unknown[];
};
type CourtlistenerCaseInput = {
clusterId?: number | null;
caseName?: string | null;
citation?: string | null;
citations?: string[];
url?: string | null;
pdfUrl?: string | null;
dateFiled?: string | null;
opinions?: unknown[];
};
export type CourtlistenerTurnState = {
casesByClusterId: Map<number, CourtlistenerCaseRecord>;
};
function nonEmpty(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function cleanAskInputString(value: unknown, fallback = ""): string {
const text = typeof value === "string" ? value.trim() : "";
return text || fallback;
}
function normalizeAskInputsEvent(args: Record<string, unknown>): AskInputsEvent {
const rawItems = Array.isArray(args.items) ? args.items : [];
const items = rawItems
.map((item, index): AskInputItem | null => {
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
const row = item as Record<string, unknown>;
const id =
cleanAskInputString(row.id) ||
`${row.kind === "documents" ? "documents" : "choice"}-${index + 1}`;
const responsePrefix = cleanAskInputString(row.response_prefix);
if (row.kind === "documents") {
const rawDocumentTypes = Array.isArray(row.document_types)
? row.document_types
: [];
const documentTypes = rawDocumentTypes
.filter((type): type is string => typeof type === "string")
.map((type) => type.trim())
.filter(Boolean)
.map((type) => type.slice(0, 300))
.slice(0, 8);
return {
id: id.slice(0, 80),
kind: "documents",
document_types: documentTypes,
...(responsePrefix
? { response_prefix: responsePrefix.slice(0, 200) }
: {}),
};
}
const question = cleanAskInputString(
row.question,
"Please choose an option.",
);
const rawOptions = Array.isArray(row.options) ? row.options : [];
const options = rawOptions
.map((option): AskInputOption | null => {
if (!option || typeof option !== "object") return null;
const optionRow = option as Record<string, unknown>;
const value =
cleanAskInputString(optionRow.value) ||
cleanAskInputString(optionRow.label);
if (!value) return null;
return {
value: value.slice(0, 500),
};
})
.filter((option): option is AskInputOption => !!option)
.slice(0, 8);
const normalizedOptions =
options.length > 0 ? options : [{ value: "Continue" }];
const otherLabel = cleanAskInputString(row.other_label, "Other");
return {
id: id.slice(0, 80),
kind: "choice",
question: question.slice(0, 500),
options: normalizedOptions,
allow_other: row.allow_other !== false,
other_label: otherLabel.slice(0, 80),
...(responsePrefix
? { response_prefix: responsePrefix.slice(0, 200) }
: {}),
};
})
.filter((item): item is AskInputItem => !!item)
.slice(0, 12);
return { type: "ask_inputs", items };
}
function upsertCourtlistenerCases(
state: CourtlistenerTurnState,
inputs: CourtlistenerCaseInput[],
): CourtlistenerCaseRecord[] {
const records: CourtlistenerCaseRecord[] = [];
for (const input of inputs) {
if (typeof input.clusterId !== "number" || !Number.isFinite(input.clusterId)) {
continue;
}
const clusterId = Math.floor(input.clusterId);
const current =
state.casesByClusterId.get(clusterId) ??
{
clusterId,
caseName: null,
citations: [],
url: null,
pdfUrl: null,
dateFiled: null,
};
const nextCitations = [
...current.citations,
...(input.citation ? [input.citation] : []),
...(input.citations ?? []),
]
.map(nonEmpty)
.filter((value): value is string => !!value);
const record: CourtlistenerCaseRecord = {
...current,
caseName: current.caseName ?? nonEmpty(input.caseName),
citations: Array.from(new Set(nextCitations)),
url: current.url ?? nonEmpty(input.url),
pdfUrl: current.pdfUrl ?? nonEmpty(input.pdfUrl),
dateFiled: current.dateFiled ?? nonEmpty(input.dateFiled),
opinions: current.opinions ?? input.opinions,
};
state.casesByClusterId.set(clusterId, record);
records.push(record);
}
return records;
}
function caseCitationEventFromRecord(
record: CourtlistenerCaseRecord,
): CaseCitationEvent | null {
if (!record.url) return null;
return {
type: "case_citation",
cluster_id: record.clusterId,
case_name: record.caseName,
citation: record.citations[0] ?? null,
url: record.url,
pdfUrl: record.pdfUrl,
dateFiled: record.dateFiled,
};
}
function recordFromUnknown(value: unknown): Record<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,
turnReadState?: TurnReadState,
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[];
askInputsEvents: AskInputsEvent[];
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 askInputsEvents: AskInputsEvent[] = [];
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" }
>[] = [];
const registerGeneratedDocument = (
tc: ToolCall,
result: Record<string, unknown>,
previewFilename: string,
fileType: string,
) => {
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;
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: fileType,
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`,
);
}
const { download_url, storage_path, ...safeToolResult } = result;
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),
});
};
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 === "ask_inputs") {
const event = normalizeAskInputsEvent(args);
if (event.items.length > 0) askInputsEvents.push(event);
continue;
}
if (tc.function.name === "read_document") {
const rawDocId = args.doc_id as string;
const docId = resolveDocLabel(rawDocId, docStore, docIndex) ?? rawDocId;
const readIdentity = await getTurnReadIdentity({
docLabel: docId,
docStore,
docIndex,
db,
});
if (readIdentity && turnReadState?.has(readIdentity.key)) {
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: duplicateReadDocumentResult(readIdentity),
});
continue;
}
const content = await readDocumentContent(
docId,
docStore,
write,
docIndex,
db,
);
const filename = docStore.get(docId)?.filename;
const documentId = docIndex?.[docId]?.document_id;
if (readIdentity && turnReadState) {
turnReadState.set(readIdentity.key, readIdentity);
}
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 readIdentity = await getTurnReadIdentity({
docLabel: docId,
docStore,
docIndex,
db,
});
if (readIdentity && turnReadState?.has(readIdentity.key)) {
const filename = docStore.get(docId)?.filename ?? docId;
parts.push(
`--- ${filename} (${docId}) ---\n${duplicateReadDocumentResult(
readIdentity,
)}`,
);
continue;
}
const content = await readDocumentContent(
docId,
docStore,
write,
docIndex,
db,
);
const filename = docStore.get(docId)?.filename ?? docId;
if (readIdentity && turnReadState) {
turnReadState.set(readIdentity.key, readIdentity);
}
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.skill_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,
});
clearTurnReadsForDocument(turnReadState, indexed.document_id);
// 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 = contentTypeForDocumentType(
sourceInfo.file_type,
);
// 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 = safeGeneratedFilename(title, "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 },
);
registerGeneratedDocument(
tc,
result as Record<string, unknown>,
previewFilename,
"docx",
);
} else if (tc.function.name === "generate_excel") {
const title = args.title as string;
devLog(`[generate_excel] title="${title}"`);
const previewFilename = safeGeneratedFilename(title, "xlsx");
write(
`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`,
);
const result = await generateExcel(
title,
args.sheets as unknown[],
userId,
db,
{ projectId: projectId ?? null },
);
registerGeneratedDocument(
tc,
result as Record<string, unknown>,
previewFilename,
"xlsx",
);
} else if (tc.function.name === "generate_ppt") {
const title = args.title as string;
devLog(`[generate_ppt] title="${title}"`);
const previewFilename = safeGeneratedFilename(title, "pptx");
write(
`data: ${JSON.stringify({ type: "doc_created_start", filename: previewFilename })}\n\n`,
);
const result = await generatePpt(
title,
args.slides as unknown[],
userId,
db,
{ projectId: projectId ?? null },
);
registerGeneratedDocument(
tc,
result as Record<string, unknown>,
previewFilename,
"pptx",
);
}
}
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,
askInputsEvents,
courtlistenerEvents,
caseCitationEvents,
mcpEvents,
};
}