import "server-only"; import type { AiGroupContext } from "@/types/ai"; // This app is single-user/self-hosted scale (see lib/access.ts's comments // on Projects being single-owner for now) -- there's no retrieval/ranking // here, the whole relevant dataset just goes straight into the prompt, // truncated only by the hard caps below. If a board ever grows large // enough for this to blow the model's context window, the fix is a // RAG-style retrieval step, not a bigger cap. const NOTE_CHAR_CAP = 4_000; const DETAILS_CHAR_CAP = 300; const TOTAL_CHAR_CAP = 60_000; function truncate(text: string, max: number): string { const trimmed = text.trim(); if (trimmed.length <= max) return trimmed; return `${trimmed.slice(0, max)}… [truncated]`; } function formatTodoLine(todo: { title: string; details: string | null; completed: boolean; completedAt: string | null; }): string { const box = todo.completed ? "[x]" : "[ ]"; const doneNote = todo.completed && todo.completedAt ? ` (done ${todo.completedAt.slice(0, 10)})` : ""; const details = todo.details?.trim() ? ` — ${truncate(todo.details, DETAILS_CHAR_CAP)}` : ""; return `- ${box} ${todo.title}${doneNote}${details}`; } /** Renders one group's notes + todos as compact markdown-ish prompt text. */ export function formatGroupForPrompt( group: { title: string; noteContent: string; todos: { title: string; details: string | null; completed: boolean; completedAt: string | null }[]; }, opts?: { archived?: boolean } ): string { const openCount = group.todos.filter((t) => !t.completed).length; const doneCount = group.todos.length - openCount; const notes = group.noteContent.trim() ? truncate(group.noteContent, NOTE_CHAR_CAP) : "(no notes)"; const todoLines = group.todos.length > 0 ? group.todos.map(formatTodoLine).join("\n") : "(no to-dos)"; const archivedTag = opts?.archived ? " [archived]" : ""; return `Group${archivedTag}: "${group.title}"\n\nNotes:\n${notes}\n\nTo-dos (${openCount} open, ${doneCount} done):\n${todoLines}`; } /** * Renders every group the user can see, grouped by board (Home/Project) * then category, for the global chat's system prompt. Archived groups * are marked inline rather than omitted, so historical questions stay * answerable. */ export function formatAllGroupsForPrompt(groups: AiGroupContext[]): string { const byScope = new Map>(); for (const group of groups) { if (!byScope.has(group.scopeLabel)) byScope.set(group.scopeLabel, new Map()); const byCategory = byScope.get(group.scopeLabel)!; if (!byCategory.has(group.categoryName)) byCategory.set(group.categoryName, []); byCategory.get(group.categoryName)!.push(group); } const sections: string[] = []; for (const [scopeLabel, byCategory] of byScope) { sections.push(`# ${scopeLabel}`); for (const [categoryName, categoryGroups] of byCategory) { sections.push(`## Category: ${categoryName}`); for (const group of categoryGroups) { const formatted = formatGroupForPrompt( { title: group.groupTitle, noteContent: group.noteContent, todos: group.todos }, { archived: group.archived } ); sections.push(`### ${formatted}`); } } } let result = sections.join("\n\n"); if (result.length > TOTAL_CHAR_CAP) { result = `${result.slice(0, TOTAL_CHAR_CAP)}\n\n[... additional groups omitted for length ...]`; } return result; }