import { and, asc, desc, eq, exists, gt, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql, } from "drizzle-orm"; import { z } from "zod"; import { commitProvenance, devices, generatedReports, gitCommits, projectRoots, projects, timelineEvents, UNASSIGNED_PROJECT_ID, user, } from "../db/schema"; import type { TimelineEventDisplay } from "../types/timeline"; import { type ActivityReport, buildActivityReport, } from "../utils/activity-report"; import { type DataFreshness, getDataFreshness, getStaleWarning, } from "../utils/freshness"; import { type StreamBillableEvent, type StreamReportCursor, type StreamReportPageLoader, streamBillableReport, } from "../utils/stream-billable-report"; import type { CapabilityContext, ReportNarrative, ReportNarrativeInput, } from "./context"; import { defineCapability } from "./context"; const SOURCE_VALUES = [ "git", "file", "claude", "claude_desktop", "gemini", "codex", "opencode", "cursor", "cline", "amp", "copilot", "grok", "linear", "manual", ] as const; export type ReportScope = "me" | "team"; export type ReportFilter = "client" | "internal" | "both"; export type ReportDepth = "summary" | "exhaustive"; /** Resolved options for the pure report builder (defaults already applied). */ export interface BuildReportPayloadOptions { scope: ReportScope; filter: ReportFilter; depth: ReportDepth; /** Range echoed into the report (display only; does not filter). */ start?: string | null; end?: string | null; } /** The pure builder's payload — only fields derivable from the events, no * DB-read freshness. `buildReportPayload` returns this; the capability's * `execute` augments it with {@link ReportPayload}'s freshness fields. */ export interface ReportPayloadBase { scope: ReportScope; filter: ReportFilter; depth: ReportDepth; report: ActivityReport; } /** Why an optional AI narrative was not attached (OPL-2812). */ export type NarrativeSkippedReason = "unsupported"; /** The capability's return shape: the built report plus the data-freshness * signal so a one-shot report request is self-aware about stale data (the * caller does not have to make a separate `getDataFreshness` call). */ export interface ReportPayload extends ReportPayloadBase { /** Ingest/sync freshness at report time. `null` in org mode when it can't be * scoped (no device list), matching `getDataFreshness`'s empty result. */ freshness: DataFreshness | null; /** Non-blocking, human-readable staleness warning, or `null` when the data is * fresh (or freshness is unknown). Same rule as `getStaleWarning`. */ staleWarning: string | null; /** * Optional AI narrative (OPL-2812). Only present when `narrative: true`, a * generator is injected on the context, and generation succeeds. FAILURE = * OMISSION: errors/timeouts leave this undefined without failing the report. */ narrative?: ReportNarrative; /** * Set when `narrative: true` but this runtime cannot produce one (desktop/ * CLI, or hosted without an injected generator). Omitted when narrative was * not requested or when generation was attempted (success or soft-fail). */ narrativeSkipped?: NarrativeSkippedReason; /** * Exhaustive depth only: where the complete per-event dataset lives. The * raw events are never inlined into the payload — they are streamed, * cap-free, from this relative endpoint (NDJSON; `?limit=` for previews). * `count` is exact, computed from the full stream. */ eventsExport?: EventsExportRef; } /** Reference to the streamed per-event export backing an exhaustive report. */ export interface EventsExportRef { /** Exact number of events in the report's range/filters. */ count: number; /** Relative URL of the cap-free NDJSON stream reproducing this report's * exact range, scope, and filters. */ url: string; } /** * Compact, structured-only slice of a report payload for the narrative model. * Never includes raw chat evidence or the full events array. */ export function toNarrativeInput( payload: ReportPayloadBase, ): ReportNarrativeInput { const { report } = payload; return { scope: payload.scope, filter: payload.filter, depth: payload.depth, report: { range: report.range, totalEstimatedMinutes: report.totalEstimatedMinutes, totalEvents: report.totalEvents, activeDays: report.activeDays, projects: report.projects.map((p) => ({ projectId: p.projectId, projectName: p.projectName, estimatedMinutes: p.estimatedMinutes, gitCommits: p.gitCommits, conversations: p.conversations, filesModified: p.filesModified, activeDays: p.activeDays, })), ...(report.outcomes ? { outcomes: report.outcomes } : {}), ...(report.ticketsByProject ? { ticketsByProject: report.ticketsByProject } : {}), ...(report.byClientType ? { byClientType: { client: { estimatedMinutes: report.byClientType.client.estimatedMinutes, eventCount: report.byClientType.client.eventCount, }, internal: { estimatedMinutes: report.byClientType.internal.estimatedMinutes, eventCount: report.byClientType.internal.eventCount, }, }, } : {}), }, }; } /** * Whether an event belongs in the report given the client/internal `filter`. * `client` = the event's project has a `clientId`; `internal` = it does not * (this includes unassigned work). `both` keeps everything. Pure and exported * so the filtering rule has one tested source of truth. */ export function matchesClientFilter( clientId: string | null | undefined, filter: ReportFilter, ): boolean { if (filter === "both") return true; if (filter === "client") return clientId != null; return clientId == null; } /** * Build the report payload from already-loaded events. Pure: the DB query in * `execute` resolves date/source/project/device filtering; this applies the * client/internal `filter`, the `depth` (whether raw events are echoed), and * always asks `buildActivityReport` for the client-vs-internal breakdown. */ export function buildReportPayload( events: TimelineEventDisplay[], options: BuildReportPayloadOptions, ): ReportPayloadBase { const { scope, filter, depth } = options; const selected = events.filter((e) => matchesClientFilter(e.clientId, filter), ); const report = buildActivityReport(selected, { start: options.start ?? null, end: options.end ?? null, includeClientBreakdown: true, includeEvents: depth === "exhaustive", }); return { scope, filter, depth, report }; } /** * Resolve the device IDs the report should be scoped to: * - `null` → no device scoping (local single-tenant mode). * - `[]` → scope to nothing (org member with no matching devices). * - `[...]` → restrict timeline events to these devices. * * `team` uses the org's member devices (the same set the chat/REST paths use, * carried on `ctx.deviceIds`); `me` restricts to the caller's own devices. * `userIds`, when given, further narrows to those members' devices. */ async function resolveScopeDeviceIds( ctx: CapabilityContext, scope: ReportScope, userIds: string[] | undefined, ): Promise { if (!ctx.organizationId) return null; if (scope === "me") { // `me` = one person's devices: the caller's, or — with explicit userIds — // the named members' devices (org-scoped). The override is what lets a // saved me-scope document's events export reproduce the CREATOR's report // when a different org member opens it. It exposes nothing team scope // doesn't already, and is strictly narrower (device-only — no // org-project arm), so the export is exactly the document's event set. const targetUserIds = userIds && userIds.length > 0 ? userIds : [ctx.userId]; const own = await ctx.db .select({ id: devices.id }) .from(devices) .where( and( inArray(devices.userId, targetUserIds), eq(devices.organizationId, ctx.organizationId), ), ); return own.map((d) => d.id); } let ids = ctx.deviceIds ?? []; if (userIds && userIds.length > 0) { const narrowed = await ctx.db .select({ id: devices.id }) .from(devices) .where( and( eq(devices.organizationId, ctx.organizationId), inArray(devices.userId, userIds), ), ); const allowed = new Set(narrowed.map((d) => d.id)); ids = ids.filter((id) => allowed.has(id)); } return ids; } /** * Build the org-scoping WHERE condition for the event query. * * - Local mode (no org) → `undefined` (single tenant, no scoping). * - `me` scope → the caller's own devices only (device-less events such as * GitHub-imported commits cannot be attributed to one person, so they are out * of a personal report by design). Empty device set → match nothing. * - `team` scope → the SAME dual rule as the hub `/api/timeline` route so a * team report shows exactly what the timeline shows: assigned events whose * project belongs to the org, OR unassigned events on an org member's device. * Both arms are org-scoped, so org isolation is preserved. * - `team` scope narrowed to specific members (`userIds`) → additionally * attribute device-less GitHub commits to a member via commit provenance, so * a filtered team report shows ONLY the requested contributors' work (see * below). */ function buildScopeCondition( ctx: CapabilityContext, scope: ReportScope, deviceIds: string[] | null, userIds?: string[], ) { if (!ctx.organizationId) return undefined; if (scope === "me") { if (!deviceIds || deviceIds.length === 0) return sql`1 = 0`; return inArray(timelineEvents.deviceId, deviceIds); } const orgProjectArm = eq(projects.organizationId, ctx.organizationId); // Team report filtered to named members. Device-borne events are already // narrowed — `deviceIds` was reduced to these members' devices upstream — but // device-less GitHub commits are assigned to org projects and would otherwise // pass the unconditional `orgProjectArm` for the WHOLE org, bleeding every // contributor's commits into a report scoped to three people. Attribute those // commits to a member through commit provenance (github login / email → user, // resolved at ingest) and keep only the requested members'. if (userIds && userIds.length > 0) { const authoredByMember = exists( ctx.db .select({ one: sql`1` }) .from(gitCommits) .innerJoin( commitProvenance, eq(commitProvenance.commitId, gitCommits.id), ) .where( and( eq(gitCommits.organizationId, ctx.organizationId), eq(gitCommits.commitKey, timelineEvents.commitKey), inArray(commitProvenance.userId, userIds), ), ), ); const arms: SQL[] = [and(orgProjectArm, authoredByMember) as SQL]; if (deviceIds && deviceIds.length > 0) { arms.push(inArray(timelineEvents.deviceId, deviceIds)); } return arms.length === 1 ? arms[0] : (or(...arms) as SQL); } if (!deviceIds || deviceIds.length === 0) return orgProjectArm; return or( orgProjectArm, and( or(isNull(timelineEvents.projectRootId), isNull(projectRoots.projectId)), inArray(timelineEvents.deviceId, deviceIds), ), ); } /** Cursor page size is an I/O/memory bound, never a correctness cap. */ const REPORT_CHUNK_SIZE = 3_000; /** Args a report event query is resolved from (generateReport's range/scope/ * filter subset — the export endpoint accepts the same set). */ export interface ReportEventQueryArgs { days?: number; start?: string; end?: string; scope?: ReportScope; filter?: ReportFilter; projectIds?: string[]; sources?: string[]; userIds?: string[]; } /** Resolved SQL + display range shared by report generation and the export. */ export interface ResolvedReportEventQuery { conditions: SQL[]; rangeStart: string | null; rangeEnd: string | null; scope: ReportScope; filter: ReportFilter; } /** * Resolve a report/export request to its WHERE conditions and display range. * ONE resolver for generateReport, saveReport, and the events-export endpoint, * so the streamed export can never include events the equivalent report would * not (same org isolation, same scope/device rules, same filters). */ export async function resolveReportEventQuery( ctx: CapabilityContext, args: ReportEventQueryArgs, ): Promise { const days = args.days ?? 7; const scope: ReportScope = args.scope ?? "me"; const filter: ReportFilter = args.filter ?? "both"; const { start, end, projectIds, sources, userIds } = args; let sinceDate: Date | null = null; let untilDate: Date | null = null; let rangeStart: string | null = null; let rangeEnd: string | null = null; if (start || end) { if (start) { sinceDate = new Date(start); rangeStart = start; } if (end) { untilDate = new Date(end); rangeEnd = end; } } else { sinceDate = new Date(); sinceDate.setDate(sinceDate.getDate() - days); rangeStart = sinceDate.toISOString(); } const scopeDeviceIds = await resolveScopeDeviceIds(ctx, scope, userIds); const conditions: SQL[] = []; const scopeCondition = buildScopeCondition( ctx, scope, scopeDeviceIds, userIds, ); if (scopeCondition) conditions.push(scopeCondition); if (sinceDate) conditions.push(gte(timelineEvents.timestamp, sinceDate)); if (untilDate) conditions.push(lte(timelineEvents.timestamp, untilDate)); if (sources && sources.length > 0) { conditions.push( inArray( timelineEvents.source, sources as (typeof SOURCE_VALUES)[number][], ), ); } if (projectIds && projectIds.length > 0) { conditions.push(inArray(projectRoots.projectId, projectIds)); // Harden: a requested project must belong to the caller's org, so a // cross-org project id can never widen the result set. if (ctx.organizationId) { conditions.push(eq(projects.organizationId, ctx.organizationId)); } } if (filter === "client") { conditions.push(isNotNull(projects.clientId)); } else if (filter === "internal") { conditions.push(isNull(projects.clientId)); } return { conditions, rangeStart, rangeEnd, scope, filter }; } /** * Cursor-paged loader over the report event query, in stable * `(projectKey, timestamp, id)` order. THE one walk used by summary rollups, * exhaustive rollups, and the NDJSON export — chunk size bounds memory per * page and is never a correctness cap. */ export function makeReportPageLoader( ctx: CapabilityContext, conditions: SQL[], ): StreamReportPageLoader { const projectKey = sql`coalesce(${projects.id}, ${UNASSIGNED_PROJECT_ID})`; return async (cursor, chunkSize) => { const pageConditions = [...conditions]; if (cursor) { const cursorCondition = or( gt(projectKey, cursor.projectKey), and( eq(projectKey, cursor.projectKey), gt(timelineEvents.timestamp, cursor.timestamp), ), and( eq(projectKey, cursor.projectKey), eq(timelineEvents.timestamp, cursor.timestamp), gt(timelineEvents.id, cursor.id), ), ); if (cursorCondition) pageConditions.push(cursorCondition); } const rows = await ctx.db .select({ projectKey, id: timelineEvents.id, timestamp: timelineEvents.timestamp, source: timelineEvents.source, sourceId: timelineEvents.sourceId, projectRootId: timelineEvents.projectRootId, metadata: timelineEvents.metadata, estimatedMinutes: timelineEvents.estimatedMinutes, projectId: projectRoots.projectId, projectName: projects.name, projectColor: projects.color, clientId: projects.clientId, deviceId: timelineEvents.deviceId, deviceName: devices.name, userId: devices.userId, userName: user.name, userImage: user.image, }) .from(timelineEvents) .leftJoin(projectRoots, eq(timelineEvents.projectRootId, projectRoots.id)) .leftJoin(projects, eq(projectRoots.projectId, projects.id)) .leftJoin(devices, eq(timelineEvents.deviceId, devices.id)) .leftJoin(user, eq(devices.userId, user.id)) .where(pageConditions.length > 0 ? and(...pageConditions) : undefined) .orderBy( asc(projectKey), asc(timelineEvents.timestamp), asc(timelineEvents.id), ) .limit(chunkSize); return rows as unknown as StreamBillableEvent[]; }; } /** * Stream the complete event set for a report query, page by page, with flat * memory — NO row cap, NO span cap. Backs the NDJSON export endpoints. The * optional `limit` is presentational (UI preview pages); omitting it walks the * entire range, however long the horizon. */ export async function* streamReportEvents( ctx: CapabilityContext, args: ReportEventQueryArgs, opts: { limit?: number; chunkSize?: number } = {}, ): AsyncGenerator { const { conditions } = await resolveReportEventQuery(ctx, args); const loadPage = makeReportPageLoader(ctx, conditions); const chunkSize = opts.chunkSize ?? REPORT_CHUNK_SIZE; let remaining = opts.limit ?? Number.POSITIVE_INFINITY; let cursor: StreamReportCursor | null = null; while (remaining > 0) { const page = await loadPage(cursor, chunkSize); if (page.length === 0) break; yield remaining >= page.length ? page : page.slice(0, remaining); remaining -= page.length; if (page.length < chunkSize) break; const last = page.at(-1); if (!last) break; const nextCursor = { projectKey: last.projectKey, timestamp: new Date(last.timestamp), id: last.id, }; if ( Number.isNaN(nextCursor.timestamp.getTime()) || (cursor && cursor.projectKey === nextCursor.projectKey && cursor.timestamp.getTime() === nextCursor.timestamp.getTime() && cursor.id === nextCursor.id) ) { throw new Error("stream_report_cursor_did_not_advance"); } cursor = nextCursor; } } /** * Relative URL of the streamed export reproducing a report's exact query. * Hosted `scope=me` is pinned to the creating user (`userIds=`, * resolved by `resolveScopeDeviceIds`'s me-with-userIds override) so any org * member opening the saved document exports exactly the events the document * summarizes — the creator's device-scoped set, not the viewer's. */ export function buildEventsExportUrl( ctx: CapabilityContext, query: { rangeStart: string | null; rangeEnd: string | null; scope: ReportScope; filter: ReportFilter; projectIds?: string[]; sources?: string[]; userIds?: string[]; }, ): string { const params = new URLSearchParams(); if (query.rangeStart) params.set("start", query.rangeStart); // Freeze an open-ended range at generation time so the export reproduces // the document rather than growing with new events. params.set("end", query.rangeEnd ?? new Date().toISOString()); params.set("scope", query.scope); const exportUserIds = ctx.organizationId && query.scope === "me" && !(query.userIds && query.userIds.length > 0) ? [ctx.userId] : (query.userIds ?? []); if (exportUserIds.length > 0) { params.set("userIds", exportUserIds.join(",")); } if (query.filter !== "both") params.set("filter", query.filter); if (query.projectIds && query.projectIds.length > 0) { params.set("projectIds", query.projectIds.join(",")); } if (query.sources && query.sources.length > 0) { params.set("sources", query.sources.join(",")); } return `/api/timeline/events/export?${params.toString()}`; } // Shared input fields for generateReport and saveReport (saveReport = the same // report plus persistence to the library). Keeping one shape means the two // tools never drift on parameters. const reportInputShape = { days: z .number() .int() .min(1) .max(3650) .optional() .describe( "Look back this many days (default 7). Ignored when start/end are given.", ), start: z .string() .optional() .describe("ISO start of an explicit range (overrides days)."), end: z.string().optional().describe("ISO end of an explicit range."), scope: z .enum(["me", "team"]) .optional() .describe( "'me' = your own devices (default), 'team' = the whole organization.", ), filter: z .enum(["client", "internal", "both"]) .optional() .describe( "'client' = projects with a client, 'internal' = projects without one, 'both' = all (default).", ), depth: z .enum(["summary", "exhaustive"]) .optional() .describe( "'summary' (default) or 'exhaustive' (adds an eventsExport reference — exact count plus a streamed NDJSON URL for the complete per-event dataset; raw events are never inlined).", ), projectIds: z .array(z.string()) .optional() .describe("Restrict to these project IDs."), sources: z .array(z.enum(SOURCE_VALUES)) .optional() .describe("Restrict to these event sources."), userIds: z .array(z.string()) .optional() .describe("Restrict to these members' devices (team scope)."), narrative: z .boolean() .optional() .describe( "Hosted only, requires ai-features. When true, attach an optional AI narrative rephrasing the structured report (stats, outcomes, tickets, projects). Default false. Desktop/CLI set narrativeSkipped=unsupported; failures omit narrative without failing the report.", ), } as const; export const generateReport = defineCapability({ name: "generateReport", description: "Generate a readable activity report (total time, source mix, active days, per-project rollups, devices, client-vs-internal split, deterministic outcomes) and return it inline for a quick in-chat summary. To also SAVE the report and get an openable/downloadable link, use saveReport. Defaults when omitted: last 7 days, scope=me, filter=both, depth=summary. Prefer explicit params: days (1-3650) or start/end ISO dates; scope me|team (team is org/hub only); filter client|internal|both; depth summary|exhaustive. Every depth streams the COMPLETE requested range — any horizon, no row or span caps, exact totals. Exhaustive additionally returns eventsExport (exact count + streamed NDJSON URL for the complete per-event dataset); raw events are never inlined in the payload. Optional narrative (hosted only, requires ai-features) for an AI rephrase of the structured outcomes. Optional projectIds, sources, userIds (narrows team). Call getDataFreshness first if recent work may be missing. Use when the user wants a work report, billable vs internal time, or 'what did the team get done'.", requiredScope: "timeline:read", // NOTE: fields are `.optional()` WITHOUT `.default()` on purpose. The // capability executor calls `execute` directly and does NOT run this schema, // so a Zod default would never be applied at runtime — `execute` applies the // documented defaults itself. Keeping the types optional matches that reality. inputSchema: z.object(reportInputShape), execute: async (ctx, args) => { // `defineCapability` does NOT run the Zod schema, so a direct/MCP caller can // pass `{}` (or omit fields). `resolveReportEventQuery` applies the // documented defaults defensively so a zero-arg call is always safe. const depth: ReportDepth = args.depth ?? "summary"; const wantNarrative = args.narrative === true; const { conditions, rangeStart, rangeEnd, scope, filter } = await resolveReportEventQuery(ctx, args); // Read freshness BEFORE the main event query so a report an agent requests // in one shot carries its own "recent work may be missing" warning — the // same signal `getDataFreshness` exposes, scoped identically. The window end // feeds the coverage-gap check (ingest older than the range end → stale). const freshness = await getDataFreshness(ctx.db, { organizationId: ctx.organizationId, deviceIds: ctx.deviceIds, }); const staleWarning = getStaleWarning(freshness, { windowEnd: rangeEnd }); // BOTH depths stream the complete requested range through the same // cursor-paged builder: flat memory, exact rollups, no row cap, no span // cap — a 3-year exhaustive report walks every event just like summary. // The depths differ only in whether the payload carries a reference to // the complete per-event export (never an inline echo — the raw events // are delivered by the streaming export endpoint, not buffered here). const report = await streamBillableReport( makeReportPageLoader(ctx, conditions), { start: rangeStart, end: rangeEnd, includeClientBreakdown: true, chunkSize: REPORT_CHUNK_SIZE, }, ); const base: ReportPayloadBase = { scope, filter, depth, report }; const payload: ReportPayload = { ...base, freshness, staleWarning, ...(depth === "exhaustive" ? { eventsExport: { count: report.totalEvents, url: buildEventsExportUrl(ctx, { rangeStart, rangeEnd, scope, filter, projectIds: args.projectIds, sources: args.sources, userIds: args.userIds, }), }, } : {}), }; // Optional AI narrative (OPL-2812). Never fails the report. if (wantNarrative) { if (!ctx.generateNarrative) { // Desktop/CLI / hosted without injection: document the skip. payload.narrativeSkipped = "unsupported"; } else { try { const narrative = await ctx.generateNarrative(toNarrativeInput(base)); if (narrative?.summary?.trim()) { payload.narrative = { summary: narrative.summary.trim(), perProject: Array.isArray(narrative.perProject) ? narrative.perProject .filter( (p) => typeof p?.projectName === "string" && typeof p?.sentence === "string" && p.projectName.trim() && p.sentence.trim(), ) .slice(0, 6) .map((p) => ({ projectName: p.projectName.trim(), sentence: p.sentence.trim(), })) : [], }; } } catch (err) { // FAILURE = OMISSION: log and return the report without narrative. console.warn( "[generateReport] narrative generation failed; omitting narrative:", err instanceof Error ? err.message : err, ); } } } return payload; }, }); // Same report as generateReport, but persisted to the reports library so the // user gets an openable/downloadable link. Hosted-hub only (the library is // org-scoped); desktop/local returns a clear message and the caller can fall // back to generateReport for an inline summary. export const saveReport = defineCapability({ name: "saveReport", description: "Generate a work report AND save it to the reports library, returning a link the user can open in the hub and export to PDF. Use when the user asks to 'generate/create/make/save a report' or wants a report they can keep, share, or download — not for a throwaway in-chat summary (use generateReport for that). Same parameters as generateReport, plus an optional title.", requiredScope: "timeline:write", inputSchema: z.object({ ...reportInputShape, title: z .string() .max(200) .optional() .describe("Report title; a date-range title is derived when omitted."), }), execute: async (ctx, { title, ...reportArgs }) => { if (!ctx.organizationId) { return { error: "Saving a report to the library requires the hosted hub (an organization). Use generateReport for an inline summary instead.", }; } // Reuse the full generateReport logic to build the payload, then persist it // exactly as POST /api/reports does. const payload = await generateReport.execute(ctx, reportArgs); const { report } = payload; const scope: ReportScope = reportArgs.scope ?? "me"; const filter: ReportFilter = reportArgs.filter ?? "both"; const depth: ReportDepth = reportArgs.depth ?? "summary"; const rangeStart = report.range.start ? new Date(report.range.start) : null; const rangeEnd = report.range.end ? new Date(report.range.end) : null; const resolvedTitle = title?.trim() || `Report — ${rangeStart ? rangeStart.toLocaleDateString() : "?"} to ${ rangeEnd ? rangeEnd.toLocaleDateString() : "?" }`; const [row] = await ctx.db .insert(generatedReports) .values({ organizationId: ctx.organizationId, createdByUserId: ctx.userId, title: resolvedTitle, status: "ready", criteria: JSON.stringify(reportArgs), scope, filter, depth, rangeStart, rangeEnd, payload: JSON.stringify(payload), totalEvents: report.totalEvents, totalEstimatedMinutes: report.totalEstimatedMinutes, activeDays: report.activeDays, completedAt: new Date(), }) .returning(); return { success: true, reportId: row.id, url: `/reports/${row.id}`, title: row.title, totalEvents: report.totalEvents, totalEstimatedMinutes: report.totalEstimatedMinutes, activeDays: report.activeDays, message: `Saved report "${row.title}". Open it at /reports/${row.id}`, }; }, }); export const listReports = defineCapability({ name: "listReports", description: "List saved reports in the reports library, newest first. Use when the user asks about reports they've generated before or wants to find one to open. Returns each report's ID, title, date range, totals, and a link.", requiredScope: "timeline:read", inputSchema: z.object({ limit: z.number().int().min(1).max(50).optional().default(10), }), execute: async (ctx, { limit }) => { if (!ctx.organizationId) { return { error: "The reports library requires the hosted hub (an organization). Use generateReport for an inline summary instead.", }; } const rows = await ctx.db .select({ id: generatedReports.id, title: generatedReports.title, status: generatedReports.status, rangeStart: generatedReports.rangeStart, rangeEnd: generatedReports.rangeEnd, totalEvents: generatedReports.totalEvents, totalEstimatedMinutes: generatedReports.totalEstimatedMinutes, activeDays: generatedReports.activeDays, createdAt: generatedReports.createdAt, }) .from(generatedReports) .where(eq(generatedReports.organizationId, ctx.organizationId)) .orderBy(desc(generatedReports.createdAt)) .limit(limit ?? 10); return { count: rows.length, reports: rows.map((r) => ({ id: r.id, title: r.title, status: r.status, url: `/reports/${r.id}`, range: r.rangeStart && r.rangeEnd ? `${new Date(r.rangeStart).toLocaleDateString()} – ${new Date(r.rangeEnd).toLocaleDateString()}` : null, totalEvents: r.totalEvents, totalHours: r.totalEstimatedMinutes ? Math.round((r.totalEstimatedMinutes / 60) * 10) / 10 : 0, activeDays: r.activeDays, })), }; }, }); export const getReport = defineCapability({ name: "getReport", description: "Fetch a saved report's full contents by ID — the per-project breakdown, totals, outcomes, and tickets. Use after listReports to open a specific report, or when the user references a report by name. Returns the structured payload so you can summarize or re-organize it.", requiredScope: "timeline:read", inputSchema: z.object({ reportId: z.string().describe("Report ID from listReports"), }), execute: async (ctx, { reportId }) => { if (!ctx.organizationId) { return { error: "The reports library requires the hosted hub (an organization).", }; } const rows = await ctx.db .select({ id: generatedReports.id, title: generatedReports.title, status: generatedReports.status, payload: generatedReports.payload, error: generatedReports.error, }) .from(generatedReports) .where( and( eq(generatedReports.id, reportId), eq(generatedReports.organizationId, ctx.organizationId), ), ) .limit(1); const row = rows[0]; if (!row) { return { error: `Report ${reportId} not found in your organization` }; } if (row.status !== "ready" || !row.payload) { return { error: row.error ?? `Report "${row.title}" is not ready (status: ${row.status}).`, }; } return { id: row.id, title: row.title, url: `/reports/${row.id}`, report: JSON.parse(row.payload), }; }, });