import { and, desc, eq, like } from "drizzle-orm"; import { z } from "zod"; import { evidenceMessages, evidenceThreads } from "../db/schema"; import { CHAT_PROVIDER_IDS } from "../types/timeline"; import { defineCapability } from "./context"; const SNIPPET_RADIUS = 100; /** ~200-char excerpt of `text` centered on the first match of `query` (case-insensitive), falling back to the start of the text when there's no match to center on. */ function buildSnippet(text: string, query: string): string { const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()); const centerIndex = matchIndex === -1 ? 0 : matchIndex; const start = Math.max(0, centerIndex - SNIPPET_RADIUS); const end = Math.min( text.length, centerIndex + query.length + SNIPPET_RADIUS, ); const prefix = start > 0 ? "…" : ""; const suffix = end < text.length ? "…" : ""; return `${prefix}${text.slice(start, end)}${suffix}`; } export const searchTranscripts = defineCapability({ name: "searchTranscripts", description: "Full-text search across your AI conversation transcripts (all messages — user, assistant, tool). Use when the user asks what was discussed, to find a conversation about a topic, or to locate a specific transcript. Returns matching messages with a link to open the full transcript.", requiredScope: "timeline:read", inputSchema: z.object({ query: z.string().min(1).describe("Search term to find in message text"), provider: z.enum(CHAT_PROVIDER_IDS).optional(), limit: z.number().min(1).max(50).optional().default(20), }), execute: async (ctx, { query, provider, limit }) => { const conditions = [like(evidenceMessages.contentText, `%${query}%`)]; if (provider) conditions.push(eq(evidenceMessages.providerId, provider)); // Org-scoped when running under the hub (organizationId set): never return // rows across tenants. Local desktop mode (organizationId null) is // already single-tenant, so no filter is applied there. if (ctx.organizationId) { conditions.push(eq(evidenceThreads.organizationId, ctx.organizationId)); } const rows = await ctx.db .select({ sessionId: evidenceThreads.externalThreadId, title: evidenceThreads.title, providerId: evidenceMessages.providerId, role: evidenceMessages.role, timestamp: evidenceMessages.timestamp, contentText: evidenceMessages.contentText, }) .from(evidenceMessages) .innerJoin( evidenceThreads, eq(evidenceMessages.threadId, evidenceThreads.id), ) .where(and(...conditions)) .orderBy(desc(evidenceMessages.timestamp)) .limit(limit || 20); return { count: rows.length, results: rows.map((r) => ({ sessionId: r.sessionId, provider: r.providerId, title: r.title, role: r.role, timestamp: r.timestamp.toISOString(), snippet: buildSnippet(r.contentText ?? "", query), url: `/timeline?conversation=${r.sessionId}&provider=${r.providerId}`, })), }; }, });