TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1/**2 * Search query language — pure, dependency-free, unit-tested (tests/unit/search-query.test.ts).3 *4 * free text "quoted phrase" model:claude provider:openai project:research folder:clients5 * after:2026-08-01 before:2026-09-01 after:7d after:today role:user|assistant is:pinned|archived|shared6 *7 * Unknown `key:value` tokens are kept as free text so typing a URL or `10:30` still searches.8 * The same module also provides highlighting/snippet helpers shared by the API (snippets) and the UI (marks).9 */1011export const FILTER_KEYS = ["model", "provider", "project", "folder", "after", "before", "role", "is"] as const;12export type FilterKey = (typeof FILTER_KEYS)[number];1314export type SearchRole = "user" | "assistant" | "system";15export type SearchFlag = "pinned" | "archived" | "shared";1617export interface SearchFilters {18 /** Substring matched against `modelKey` and the registry display name. */19 model: string[];20 /** Provider id (normalized to lower case). */21 provider: string[];22 /** Project id or name (substring). */23 project: string[];24 /** Folder id or name (substring). */25 folder: string[];26 /** Inclusive lower bound. */27 after: Date | null;28 /** Exclusive upper bound (`before:2026-09-01` = strictly before that day). */29 before: Date | null;30 role: SearchRole | null;31 is: SearchFlag[];32}3334export interface ParsedQuery {35 /** Original input. */36 raw: string;37 /** Free text (tokens + phrases) joined by spaces, used for full-text search. */38 text: string;39 /** Individual free-text words (lower-cased, deduped, order preserved). */40 words: string[];41 /** Quoted phrases (original case). */42 phrases: string[];43 /** Words + phrases, lower-cased — what to highlight. */44 terms: string[];45 filters: SearchFilters;46 /** True when at least one filter is set. */47 hasFilters: boolean;48 /** Tokens as typed (for chips): `{ key, value, raw }`. */49 tokens: FilterToken[];50}5152export interface FilterToken {53 key: FilterKey;54 value: string;55 /** Exact substring of the raw query (used to remove the chip). */56 raw: string;57}5859const ROLE_ALIASES: Record<string, SearchRole> = { user: "user", me: "user", you: "user", human: "user", assistant: "assistant", ai: "assistant", model: "assistant", bot: "assistant", system: "system" };60const FLAGS: SearchFlag[] = ["pinned", "archived", "shared"];6162function emptyFilters(): SearchFilters {63 return { model: [], provider: [], project: [], folder: [], after: null, before: null, role: null, is: [] };64}6566/** Tokenizer: whitespace-separated, double quotes group words (also inside `key:"…"`). */67export function tokenize(input: string): string[] {68 const out: string[] = [];69 let cur = "";70 let quoted = false;71 for (let i = 0; i < input.length; i++) {72 const ch = input[i];73 if (ch === '"') {74 quoted = !quoted;75 cur += ch;76 continue;77 }78 if (!quoted && /\s/.test(ch)) {79 if (cur) out.push(cur);80 cur = "";81 continue;82 }83 cur += ch;84 }85 if (cur) out.push(cur);86 return out;87}8889const DATE_RE = /^(\d{4})-(\d{2})(?:-(\d{2}))?$/;90const REL_RE = /^(\d{1,3})\s*(d|day|days|w|week|weeks|m|month|months|y|year|years)$/i;9192/** Parses absolute (`2026-08-01`, `2026-08`) and relative (`7d`, `2w`, `today`, `yesterday`) dates → UTC midnight. */93export function parseDateToken(value: string, now: Date, edge: "start" | "end"): Date | null {94 const v = value.trim().toLowerCase();95 if (!v) return null;96 const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());97 const day = 86_400_000;98 const abs = DATE_RE.exec(v);99 if (abs) {100 const y = Number(abs[1]);101 const mo = Number(abs[2]) - 1;102 if (mo < 0 || mo > 11) return null;103 if (abs[3]) {104 const d = Number(abs[3]);105 if (d < 1 || d > 31) return null;106 const t = Date.UTC(y, mo, d);107 return new Date(edge === "start" ? t : t + day);108 }109 // whole month110 return new Date(edge === "start" ? Date.UTC(y, mo, 1) : Date.UTC(y, mo + 1, 1));111 }112 if (v === "today") return new Date(edge === "start" ? todayUtc : todayUtc + day);113 if (v === "yesterday") return new Date(edge === "start" ? todayUtc - day : todayUtc);114 if (v === "week" || v === "thisweek") return new Date(edge === "start" ? todayUtc - 7 * day : todayUtc + day);115 if (v === "month" || v === "thismonth") return new Date(edge === "start" ? Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) : todayUtc + day);116 const rel = REL_RE.exec(v);117 if (rel) {118 const n = Number(rel[1]);119 const unit = rel[2][0];120 const days = unit === "d" ? n : unit === "w" ? n * 7 : unit === "m" ? n * 30 : n * 365;121 const t = todayUtc - days * day;122 return new Date(edge === "start" ? t : t + day);123 }124 return null;125}126127function unquote(s: string): string {128 return s.replace(/^"|"$/g, "");129}130131/**132 * Parse the query language. Pure: pass `now` for deterministic relative dates.133 */134export function parseQuery(raw: string, opts: { now?: Date } = {}): ParsedQuery {135 const now = opts.now ?? new Date();136 const filters = emptyFilters();137 const words: string[] = [];138 const phrases: string[] = [];139 const tokens: FilterToken[] = [];140 const seenWords = new Set<string>();141142 for (const tok of tokenize(raw.trim().slice(0, 500))) {143 const m = /^([a-zA-Z]+):(.+)$/.exec(tok);144 if (m && (FILTER_KEYS as readonly string[]).includes(m[1].toLowerCase())) {145 const key = m[1].toLowerCase() as FilterKey;146 const value = unquote(m[2]).trim();147 if (!value) continue;148 let accepted = true;149 switch (key) {150 case "model":151 filters.model.push(value.toLowerCase());152 break;153 case "provider":154 filters.provider.push(value.toLowerCase());155 break;156 case "project":157 filters.project.push(value);158 break;159 case "folder":160 filters.folder.push(value);161 break;162 case "after": {163 const d = parseDateToken(value, now, "start");164 if (d) filters.after = d;165 else accepted = false;166 break;167 }168 case "before": {169 const d = parseDateToken(value, now, "end");170 if (d) filters.before = d;171 else accepted = false;172 break;173 }174 case "role": {175 const r = ROLE_ALIASES[value.toLowerCase()];176 if (r) filters.role = r;177 else accepted = false;178 break;179 }180 case "is": {181 const flag = value.toLowerCase() as SearchFlag;182 if (FLAGS.includes(flag)) {183 if (!filters.is.includes(flag)) filters.is.push(flag);184 } else accepted = false;185 break;186 }187 }188 if (accepted) {189 tokens.push({ key, value, raw: tok });190 continue;191 }192 // invalid filter value → fall through and treat as free text193 }194 if (tok.startsWith('"') && tok.endsWith('"') && tok.length >= 2) {195 const p = unquote(tok).trim();196 if (p) phrases.push(p);197 continue;198 }199 const w = unquote(tok).trim();200 if (!w) continue;201 const lw = w.toLowerCase();202 if (!seenWords.has(lw)) {203 seenWords.add(lw);204 words.push(lw);205 }206 }207208 const terms = Array.from(new Set([...phrases.map((p) => p.toLowerCase()), ...words]));209 const text = [...words, ...phrases.map((p) => `"${p}"`)].join(" ");210 const hasFilters = filters.model.length > 0 || filters.provider.length > 0 || filters.project.length > 0 || filters.folder.length > 0 || filters.after !== null || filters.before !== null || filters.role !== null || filters.is.length > 0;211 return { raw, text, words, phrases, terms, filters, hasFilters, tokens };212}213214/** Single-valued keys are replaced when appended again. */215const SINGLE_KEYS: FilterKey[] = ["after", "before", "role"];216217/** Quote a filter value when it contains whitespace. */218export function formatFilterToken(key: FilterKey, value: string): string {219 const v = value.trim();220 return `${key}:${/\s/.test(v) ? `"${v.replace(/"/g, "")}"` : v}`;221}222223/** Append (or replace, for single-valued keys) a filter to a raw query string. */224export function addFilter(raw: string, key: FilterKey, value: string): string {225 const token = formatFilterToken(key, value);226 const parsed = parseQuery(raw);227 let rest = tokenize(raw.trim());228 if (SINGLE_KEYS.includes(key)) {229 const existing = new Set(parsed.tokens.filter((t) => t.key === key).map((t) => t.raw));230 rest = rest.filter((t) => !existing.has(t));231 } else if (parsed.tokens.some((t) => t.key === key && t.value.toLowerCase() === value.trim().toLowerCase())) {232 return raw.trim();233 }234 return [...rest, token].join(" ").trim();235}236237/** Remove one filter token (exact raw match) from the query. */238export function removeFilter(raw: string, token: FilterToken): string {239 const toks = tokenize(raw.trim());240 const idx = toks.indexOf(token.raw);241 if (idx >= 0) toks.splice(idx, 1);242 return toks.join(" ").trim();243}244245/** Remove every filter, keeping the free text. */246export function stripFilters(raw: string): string {247 const parsed = parseQuery(raw);248 const drop = new Set(parsed.tokens.map((t) => t.raw));249 return tokenize(raw.trim())250 .filter((t) => !drop.has(t))251 .join(" ")252 .trim();253}254255/* ------------------------------------------------------------------------------------------------256 * Highlighting & snippets257 * ---------------------------------------------------------------------------------------------- */258259export interface Segment {260 text: string;261 hit: boolean;262}263264function escapeRegExp(s: string): string {265 return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");266}267268/** Split `text` into hit / non-hit segments for the given terms (case-insensitive, longest term wins). */269export function highlightSegments(text: string, terms: string[]): Segment[] {270 const clean = terms.map((t) => t.trim()).filter((t) => t.length > 0);271 if (!text || clean.length === 0) return text ? [{ text, hit: false }] : [];272 const sorted = Array.from(new Set(clean)).sort((a, b) => b.length - a.length);273 const re = new RegExp(sorted.map(escapeRegExp).join("|"), "gi");274 const out: Segment[] = [];275 let last = 0;276 for (const m of text.matchAll(re)) {277 const start = m.index ?? 0;278 if (start > last) out.push({ text: text.slice(last, start), hit: false });279 out.push({ text: m[0], hit: true });280 last = start + m[0].length;281 }282 if (last < text.length) out.push({ text: text.slice(last), hit: false });283 return out;284}285286/** Lightweight markdown → plain text for snippets (no HTML, no dependencies). */287export function stripMarkdown(md: string): string {288 return md289 .replace(/```[\w+-]*\n?/g, "")290 .replace(/^\s{0,3}#{1,6}\s+/gm, "")291 .replace(/^\s{0,3}>\s?/gm, "")292 .replace(/^\s*[-*+]\s+/gm, "")293 .replace(/^\s*\d+[.)]\s+/gm, "")294 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")295 .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")296 .replace(/(\*\*|__)(.*?)\1/g, "$2")297 .replace(/(\*|_)(?=\S)(.*?)(?<=\S)\1/g, "$2")298 .replace(/~~(.*?)~~/g, "$1")299 .replace(/`([^`]*)`/g, "$1")300 .replace(/\|/g, " ")301 .replace(/\s+/g, " ")302 .trim();303}304305/**306 * Build a short excerpt centred on the first matching term. Returns the full (trimmed) text when it is short.307 */308export function makeSnippet(text: string, terms: string[], span = 160): string {309 const plain = stripMarkdown(text);310 if (plain.length <= span) return plain;311 const lower = plain.toLowerCase();312 let idx = -1;313 for (const t of terms) {314 const i = lower.indexOf(t.toLowerCase());315 if (i >= 0 && (idx < 0 || i < idx)) idx = i;316 }317 if (idx < 0) return plain.slice(0, span).trimEnd() + "…";318 let start = Math.max(0, idx - Math.floor(span / 3));319 // snap to a word boundary320 if (start > 0) {321 const sp = plain.lastIndexOf(" ", start);322 if (sp > 0 && start - sp < 24) start = sp + 1;323 }324 let end = Math.min(plain.length, start + span);325 if (end < plain.length) {326 const sp = plain.indexOf(" ", end);327 if (sp > 0 && sp - end < 24) end = sp;328 }329 return (start > 0 ? "…" : "") + plain.slice(start, end).trim() + (end < plain.length ? "…" : "");330}331332/** Escape `%`/`_`/`\` for ILIKE patterns. */333export function escapeLike(s: string): string {334 return s.replace(/[\\%_]/g, "\\$&");335}336337/** `to_tsquery('simple', …)` prefix expression: `'foo':* & 'bar':*` (lexemes quoted). Returns null when nothing usable. */338export function prefixTsQuery(words: string[]): string | null {339 const lexemes = words340 .map((w) => w.replace(/['\\:&|!()<>*]/g, "").trim())341 .filter((w) => w.length >= 2);342 if (!lexemes.length) return null;343 return lexemes.map((w) => `'${w}':*`).join(" & ");344}345