/** * pi-loom: Temporal Query Parser * * Zero-LLM regex-based time-aware query parsing, aligned with Mem0 v3's * Temporal Reasoning feature. Extracts temporal intent from natural language * queries and maps them to SQL-compatible date ranges. * * Supported patterns: * - "last week/month/year" → relative date ranges * - "today/yesterday" → specific day ranges * - "this week/month" → current period * - "recently" → last 7 days * - "as of March 2025" → absolute date anchor * - "upcoming" / "planned" → future/unexpired * - "right now" / "currently" → active + recent */ /** Result of temporal query parsing. null means no temporal intent detected. */ export interface TemporalFilter { /** ISO 8601 start date for created_at range (inclusive). */ startDate?: string; /** ISO 8601 end date for created_at range (exclusive). */ endDate?: string; /** Only include memories without an expiration (upcoming/active). */ unexpiredOnly?: boolean; /** The query with temporal phrases removed for cleaner keyword matching. */ cleanQuery: string; } /** Relative date helpers */ function daysAgoISO(days: number): string { const d = new Date(Date.now() - days * 86400000); return d.toISOString().slice(0, 10); } function todayISO(daysOffset = 0): string { const d = new Date(Date.now() + daysOffset * 86400000); return d.toISOString().slice(0, 10); } function mondayThisWeekISO(): string { const now = new Date(); const day = now.getDay(); // 0=Sun, 1=Mon const diff = day === 0 ? 6 : day - 1; // days since Monday const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff); return mon.toISOString().slice(0, 10); } function firstOfMonthISO(offsetMonths = 0): string { const now = new Date(); const d = new Date(now.getFullYear(), now.getMonth() + offsetMonths, 1); return d.toISOString().slice(0, 10); } /** Parse month name + year like "March 2025", "Jan 2026" */ const MONTH_NAMES = [ "january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december", ]; const MONTH_ABBR: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, }; function parseMonthYear(s: string): Date | null { // "March 2025", "Mar 2025" const m = s.match(/^([a-z]{3,9})\s+(\d{4})$/i); if (!m) return null; const monthStr = m[1].toLowerCase(); const year = parseInt(m[2], 10); let month: number | undefined; if (monthStr.length === 3) { month = MONTH_ABBR[monthStr]; } else { const idx = MONTH_NAMES.indexOf(monthStr); if (idx >= 0) month = idx; } if (month === undefined) return null; return new Date(year, month, 1); } /** * Parse a natural language query and extract temporal intent. * Returns null if no temporal pattern is detected. */ export function parseTemporalQuery(query: string): TemporalFilter | null { const q = query.toLowerCase().trim(); // ─── "as of DATE" — absolute anchor ──────────────────── // "as of March 2025", "as of 2025-03-15" let m = q.match(/as\s+of\s+([a-z]{3,9}\s+\d{4}|\d{4}-\d{2}-\d{2})/i); if (m) { const dateStr = m[1]; let endDate: string; if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { endDate = dateStr; } else { const ym = parseMonthYear(dateStr); if (!ym) return null; // End of that month const lastDay = new Date(ym.getFullYear(), ym.getMonth() + 1, 0); endDate = lastDay.toISOString().slice(0, 10); } return { endDate, cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "last week" / "past week" ───────────────────────── m = q.match(/(?:last|past|previous)\s+week/); if (m) { return { startDate: daysAgoISO(7), endDate: todayISO(1), // up to now cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "last month" / "past month" ─────────────────────── m = q.match(/(?:last|past|previous)\s+month/); if (m) { return { startDate: daysAgoISO(30), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "last year" / "past year" ───────────────────────── m = q.match(/(?:last|past|previous)\s+year/); if (m) { return { startDate: daysAgoISO(365), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "today" ─────────────────────────────────────────── m = /\btoday\b/.exec(q); if (m) { return { startDate: todayISO(), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "yesterday" ─────────────────────────────────────── m = /\byesterday\b/.exec(q); if (m) { return { startDate: daysAgoISO(1), endDate: todayISO(), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "this week" ─────────────────────────────────────── m = /\bthis\s+week\b/.exec(q); if (m) { return { startDate: mondayThisWeekISO(), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "this month" ────────────────────────────────────── m = /\bthis\s+month\b/.exec(q); if (m) { return { startDate: firstOfMonthISO(), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "recently" ──────────────────────────────────────── m = /\brecently\b/.exec(q); if (m) { return { startDate: daysAgoISO(7), endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "upcoming" / "planned" / "next week" ────────────── m = q.match(/\b(upcoming|planned|next\s+week)\b/); if (m) { return { unexpiredOnly: true, // exclude expired, keep everything else cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "right now" / "currently" / "current" ───────────── m = q.match(/\b(right\s+now|currently|current)\b/); if (m) { return { startDate: daysAgoISO(7), endDate: todayISO(1), unexpiredOnly: true, cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "before DATE" ───────────────────────────────────── m = q.match(/before\s+([a-z]{3,9}\s+\d{4}|\d{4}-\d{2}-\d{2})/i); if (m) { const ym = parseMonthYear(m[1]); const endDate = ym ? ym.toISOString().slice(0, 10) : m[1]; return { endDate, cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "after DATE" ────────────────────────────────────── m = q.match(/after\s+([a-z]{3,9}\s+\d{4}|\d{4}-\d{2}-\d{2})/i); if (m) { const ym = parseMonthYear(m[1]); const startDate = ym ? ym.toISOString().slice(0, 10) : m[1]; return { startDate, endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } // ─── "since DATE" ────────────────────────────────────── m = q.match(/since\s+([a-z]{3,9}\s+\d{4}|\d{4}-\d{2}-\d{2})/i); if (m) { const ym = parseMonthYear(m[1]); const startDate = ym ? ym.toISOString().slice(0, 10) : m[1]; return { startDate, endDate: todayISO(1), cleanQuery: q.replace(m[0], "").trim(), }; } return null; } /** Normalize whitespace in a query string (collapse runs → single space). */ export function normalizeQuery(q: string): string { return q.replace(/\s+/g, " ").trim(); }