import { createId } from "@paralleldrive/cuid2"; import { and, desc, eq, gte, like } from "drizzle-orm"; import { z } from "zod"; import { devices, linearProjectMappings, projectRoots, projects, timelineEvents, } from "../db/schema"; import { resolveLinearProjectId } from "../projects/linear-mapping"; import { isLinearMetadata } from "../types/timeline"; import { summarizeActivity } from "../utils/time-ranges"; import { type CapabilityContext, defineCapability } from "./context"; import { buildTimelineScope } from "./scope"; /** * Find (or lazily create) the synthetic `manual://` project root that * every manual timeline entry hangs off. Shared by createTimelineEntry and * updateTimelineEntry (reassignment) so the two never drift. */ async function findOrCreateManualRoot( ctx: CapabilityContext, projectId: string, ): Promise { const manualPath = `manual://${projectId}`; const existingRoot = await ctx.db .select({ id: projectRoots.id }) .from(projectRoots) .where(eq(projectRoots.path, manualPath)) .limit(1); if (existingRoot[0]?.id) return existingRoot[0].id; const [newRoot] = await ctx.db .insert(projectRoots) .values({ id: createId(), path: manualPath, projectId, detectedName: "Manual Entries", }) .returning(); return newRoot.id; } const SOURCE_VALUES = [ "git", "file", "claude", "claude_desktop", "gemini", "codex", "opencode", "cursor", "cline", "amp", "copilot", "grok", "linear", "manual", ] as const; export const searchTimeline = defineCapability({ name: "searchTimeline", description: "Search timeline events by keyword. Use when the user asks about specific commits, file changes, or activity containing certain terms.", requiredScope: "timeline:read", inputSchema: z.object({ query: z.string().describe("Search term to find in event metadata"), source: z.enum(SOURCE_VALUES).optional(), limit: z.number().optional().default(20), }), execute: async (ctx, { query, source, limit }) => { const conditions = [like(timelineEvents.metadata, `%${query}%`)]; if (source) conditions.push(eq(timelineEvents.source, source)); const scope = buildTimelineScope(ctx); if (scope) conditions.push(scope); const events = await ctx.db .select({ id: timelineEvents.id, timestamp: timelineEvents.timestamp, source: timelineEvents.source, metadata: timelineEvents.metadata, estimatedMinutes: timelineEvents.estimatedMinutes, }) .from(timelineEvents) .where(and(...conditions)) .orderBy(desc(timelineEvents.timestamp)) .limit(limit || 20); return { count: events.length, events: events.map((e) => ({ timestamp: e.timestamp.toISOString(), source: e.source, minutes: e.estimatedMinutes, metadata: JSON.stringify(e.metadata).slice(0, 200), })), }; }, }); export const getRecentActivity = defineCapability({ name: "getRecentActivity", description: "Get recent timeline activity summary. Use when users ask about recent work, what happened today/this week, or want an activity overview.", requiredScope: "timeline:read", inputSchema: z.object({ days: z.number().optional().default(7), }), execute: async (ctx, { days }) => { const since = new Date(); since.setDate(since.getDate() - (days || 7)); const conditions = [gte(timelineEvents.timestamp, since)]; const scope = buildTimelineScope(ctx); if (scope) conditions.push(scope); // Include projectId so concurrent multi-project sessions bill fully // (per-project gap-merge, then sum) rather than collapsing wall-clock. const events = await ctx.db .select({ id: timelineEvents.id, source: timelineEvents.source, timestamp: timelineEvents.timestamp, projectId: projectRoots.projectId, estimatedMinutes: timelineEvents.estimatedMinutes, }) .from(timelineEvents) .leftJoin(projectRoots, eq(timelineEvents.projectRootId, projectRoots.id)) .where(and(...conditions)); const summary = summarizeActivity(events); return { period: `Last ${days} days`, totalEvents: events.length, totalHours: Math.round((summary.totalMinutes / 60) * 10) / 10, bySource: summary.bySource.map((e) => ({ source: e.source, events: e.events, hours: Math.round((e.minutes / 60) * 10) / 10, })), }; }, }); export const getProjectActivity = defineCapability({ name: "getProjectActivity", description: "Get activity summary for a specific project. Use when users ask about a particular project's hours or recent work.", requiredScope: "timeline:read", inputSchema: z.object({ projectName: z.string().describe("Name of the project to look up"), days: z.number().optional().default(30), }), execute: async (ctx, { projectName, days }) => { const projectConditions = [like(projects.name, `%${projectName}%`)]; if (ctx.organizationId) { projectConditions.push(eq(projects.organizationId, ctx.organizationId)); } const project = await ctx.db .select({ id: projects.id, name: projects.name }) .from(projects) .where(and(...projectConditions)) .limit(1); if (project.length === 0) { return { error: `Project "${projectName}" not found` }; } const since = new Date(); since.setDate(since.getDate() - (days || 30)); const scope = buildTimelineScope(ctx); const joinConditions = [ eq(projectRoots.projectId, project[0].id), gte(timelineEvents.timestamp, since), ]; if (scope) joinConditions.push(scope); const events = await ctx.db .select({ id: timelineEvents.id, source: timelineEvents.source, timestamp: timelineEvents.timestamp, }) .from(timelineEvents) .innerJoin( projectRoots, eq(timelineEvents.projectRootId, projectRoots.id), ) .where(and(...joinConditions)); // Linear events carry a null projectRootId (an API source has no folder to // path-match), so the projectRoots inner join above always drops them. // Additively pull in the Linear events whose Linear project name is // explicitly linked to this project, resolving the mapping at query time // (see OPL-2675). Org-scoped so a mapping can't attribute cross-tenant. const mappingConditions = [ eq(linearProjectMappings.projectId, project[0].id), ]; if (ctx.organizationId) { mappingConditions.push( eq(linearProjectMappings.organizationId, ctx.organizationId), ); } const mappings = await ctx.db .select() .from(linearProjectMappings) .where(and(...mappingConditions)); const linearEvents: Array<{ id: string; source: string; timestamp: Date }> = []; if (mappings.length > 0) { const linearConditions = [ eq(timelineEvents.source, "linear"), gte(timelineEvents.timestamp, since), ]; if (scope) linearConditions.push(scope); const linearRows = await ctx.db .select({ id: timelineEvents.id, source: timelineEvents.source, timestamp: timelineEvents.timestamp, metadata: timelineEvents.metadata, }) .from(timelineEvents) .where(and(...linearConditions)); for (const row of linearRows) { if ( isLinearMetadata(row.metadata) && resolveLinearProjectId(row.metadata.projectName, mappings) === project[0].id ) { linearEvents.push({ id: row.id, source: row.source, timestamp: row.timestamp, }); } } } const summary = summarizeActivity([...events, ...linearEvents]); return { project: project[0].name, period: `Last ${days} days`, totalHours: Math.round((summary.totalMinutes / 60) * 10) / 10, bySource: summary.bySource.map((e) => ({ source: e.source, events: e.events, hours: Math.round((e.minutes / 60) * 10) / 10, })), }; }, }); export const createTimelineEntry = defineCapability({ name: "createTimelineEntry", description: "Create a manual timeline entry for the caller's organization. Use this when the user explicitly asks to log time, record work, or add a manual entry. If you do not already have the projectId, call listProjects first.", requiredScope: "timeline:write", inputSchema: z.object({ projectId: z.string().describe("Project ID from listProjects"), title: z.string().min(1).max(200), description: z.string().optional(), duration: z .number() .int() .min(1) .max(480) .describe("Duration in minutes (1–480)"), timestamp: z.string().optional().describe("ISO timestamp; defaults to now"), }), execute: async ( ctx, { projectId, title, description, duration, timestamp }, ) => { const projConds = [eq(projects.id, projectId)]; if (ctx.organizationId) { projConds.push(eq(projects.organizationId, ctx.organizationId)); } const project = await ctx.db .select({ id: projects.id, name: projects.name }) .from(projects) .where(and(...projConds)) .limit(1); if (project.length === 0) { return { error: `Project ${projectId} not found in your organization` }; } const projectRootId = await findOrCreateManualRoot(ctx, projectId); // Attribute to the caller's OWN device (never another member's), so the // entry's device correctly reflects who logged it. Falls back to the // org's first device (or null in local mode / for un-synced users) when // the caller has no device of their own. const ownDeviceConds = [eq(devices.userId, ctx.userId)]; if (ctx.organizationId) { ownDeviceConds.push(eq(devices.organizationId, ctx.organizationId)); } const ownDevice = await ctx.db .select({ id: devices.id }) .from(devices) .where(and(...ownDeviceConds)) .limit(1); const deviceId = ownDevice[0]?.id ?? ctx.deviceIds?.[0] ?? null; const eventId = createId(); const [event] = await ctx.db .insert(timelineEvents) .values({ id: eventId, timestamp: timestamp ? new Date(timestamp) : new Date(), source: "manual", projectRootId, sourceId: eventId, metadata: { title: title.trim(), ...(description ? { description: description.trim() } : {}), }, estimatedMinutes: duration, deviceId, }) .returning(); return { success: true, entryId: event.id, project: project[0].name, message: `Logged "${title}" (${duration}min) on ${project[0].name}`, }; }, }); /** * Load a manual entry and verify it belongs to the caller's org. Returns an * error string (for the capability to surface) or the resolved row. Only * `source: "manual"` entries are mutable — scanned events are re-derived on * every scan, so editing them would be silently undone. */ async function loadOwnedManualEntry( ctx: CapabilityContext, entryId: string, ): Promise< | { error: string } | { id: string; metadata: (typeof timelineEvents.$inferSelect)["metadata"]; } > { const rows = await ctx.db .select({ id: timelineEvents.id, source: timelineEvents.source, metadata: timelineEvents.metadata, orgId: projects.organizationId, }) .from(timelineEvents) .leftJoin(projectRoots, eq(timelineEvents.projectRootId, projectRoots.id)) .leftJoin(projects, eq(projectRoots.projectId, projects.id)) .where(eq(timelineEvents.id, entryId)) .limit(1); const entry = rows[0]; if (!entry) return { error: `Entry ${entryId} not found` }; if (entry.source !== "manual") { return { error: "Only manual entries can be modified. This entry came from a scanned source and would reappear on the next scan.", }; } if (ctx.organizationId && entry.orgId !== ctx.organizationId) { return { error: `Entry ${entryId} not found in your organization` }; } return { id: entry.id, metadata: entry.metadata }; } export const updateTimelineEntry = defineCapability({ name: "updateTimelineEntry", description: "Update a manual timeline entry: change its title, description, duration, or timestamp, or reassign it to a different project. Only manual entries can be edited. Call searchTimeline or getRecentActivity to find the entry ID.", requiredScope: "timeline:write", inputSchema: z.object({ entryId: z.string().describe("Timeline event ID of the manual entry"), title: z.string().min(1).max(200).optional(), description: z.string().optional(), projectId: z .string() .optional() .describe("Reassign the entry to this project (from listProjects)"), duration: z .number() .int() .min(1) .max(480) .optional() .describe("Duration in minutes (1–480)"), timestamp: z.string().optional().describe("ISO timestamp"), }), execute: async ( ctx, { entryId, title, description, projectId, duration, timestamp }, ) => { const loaded = await loadOwnedManualEntry(ctx, entryId); if ("error" in loaded) return loaded; const updates: Partial = {}; if (projectId !== undefined) { const projConds = [eq(projects.id, projectId)]; if (ctx.organizationId) { projConds.push(eq(projects.organizationId, ctx.organizationId)); } const proj = await ctx.db .select({ id: projects.id }) .from(projects) .where(and(...projConds)) .limit(1); if (proj.length === 0) { return { error: `Project ${projectId} not found in your organization` }; } updates.projectRootId = await findOrCreateManualRoot(ctx, projectId); } if (title !== undefined || description !== undefined) { const meta = (loaded.metadata ?? {}) as { title?: string; description?: string; }; const nextTitle = title !== undefined ? title.trim() : meta.title; const nextDescription = description !== undefined ? description.trim() : meta.description; updates.metadata = { ...(nextTitle ? { title: nextTitle } : {}), ...(nextDescription ? { description: nextDescription } : {}), } as typeof timelineEvents.$inferInsert.metadata; } if (duration !== undefined) updates.estimatedMinutes = duration; if (timestamp !== undefined) updates.timestamp = new Date(timestamp); if (Object.keys(updates).length === 0) { return { error: "No fields to update. Provide at least one of: title, description, projectId, duration, timestamp.", }; } const [updated] = await ctx.db .update(timelineEvents) .set(updates) .where(eq(timelineEvents.id, entryId)) .returning(); return { success: true, entryId: updated.id, message: "Updated manual entry", }; }, }); export const deleteTimelineEntry = defineCapability({ name: "deleteTimelineEntry", description: "Delete a manual timeline entry. Only manual entries can be deleted (scanned events reappear on the next scan). Call searchTimeline or getRecentActivity to find the entry ID.", requiredScope: "timeline:write", inputSchema: z.object({ entryId: z.string().describe("Timeline event ID of the manual entry"), }), execute: async (ctx, { entryId }) => { const loaded = await loadOwnedManualEntry(ctx, entryId); if ("error" in loaded) return loaded; await ctx.db.delete(timelineEvents).where(eq(timelineEvents.id, entryId)); return { success: true, message: "Deleted manual entry" }; }, });