{"version":3,"file":"search-B69cQwDB.mjs","names":[],"sources":["../src/memory/search.ts"],"sourcesContent":["/**\n * Search over the PAI federation memory index.\n *\n * Provides three search modes:\n *  - keyword  — BM25 full-text search (default, fast, no ML required)\n *  - semantic — Brute-force cosine similarity over pre-computed embeddings\n *  - hybrid   — Normalized combination of BM25 + cosine scores\n *\n * BM25 uses SQLite's FTS5 extension.  Semantic search requires embeddings to\n * have been generated first via `embedChunks()` in the indexer.\n */\n\nimport type { Database } from \"better-sqlite3\";\nimport { deserializeEmbedding, cosineSimilarity } from \"./embeddings.js\";\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SearchResult {\n  projectId: number;\n  projectSlug?: string;   // populated from registry after search when available\n  path: string;\n  startLine: number;\n  endLine: number;\n  snippet: string;\n  score: number;          // raw BM25 score (lower = more relevant in FTS5)\n  tier: string;\n  source: string;\n  updatedAt?: number;     // Unix ms from memory_chunks.updated_at\n  lastAccessedAt?: number; // Unix ms from memory_chunks.last_accessed_at (QW2)\n  chunkId?: string;        // chunk ID for last_accessed_at update (QW2)\n}\n\nexport interface SearchOptions {\n  /** Restrict search to these project IDs. */\n  projectIds?: number[];\n  /** Restrict to 'memory' or 'notes' sources. */\n  sources?: string[];\n  /** Restrict to specific tier(s): 'evergreen' | 'daily' | 'topic' | 'session' */\n  tiers?: string[];\n  /** Maximum number of results to return. Default 10. */\n  maxResults?: number;\n  /** Minimum BM25 score threshold (FTS5 scores are negative; 0.0 means no filter). */\n  minScore?: number;\n}\n\n// STOP_WORDS imported from utils/stop-words.ts\n\n// ---------------------------------------------------------------------------\n// Query builder\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a free-text query into an FTS5 query string.\n *\n * Strategy:\n *  1. Tokenise by whitespace and punctuation\n *  2. Remove stop words and tokens shorter than 2 characters\n *  3. Double-quote each remaining token (exact word form)\n *  4. Join with OR so that any matching token returns a result\n *\n * Using OR instead of AND is critical for multi-word queries: the words rarely\n * all appear in the same chunk, so AND would return zero results.  FTS5 BM25\n * scoring naturally ranks chunks where more terms match higher, so the most\n * relevant chunks still surface at the top.\n *\n * Example: \"Synchrotech interview follow-up Gilles\"\n *   → `\"synchrotech\" OR \"interview\" OR \"follow\" OR \"gilles\"`\n *   → chunks matching any term, ranked by how many terms match\n */\n/**\n * Did SQLite reject the QUERY, or fail at the STORE?\n *\n * Only the first justifies an empty result. FTS5 reports a bad MATCH expression\n * with a recognisable message; a missing table, a corrupt index or a locked\n * database do not, and must not be reported as \"nothing found\".\n *\n * Matching on message text is unlovely, and the alternative — treating every\n * failure as empty — is what produced a confidently wrong answer to a human.\n */\nexport function isQuerySyntaxError(e: unknown): boolean {\n  const msg = e instanceof Error ? e.message : String(e);\n  return /fts5|malformed MATCH|syntax error|unterminated string|no such column/i.test(msg);\n}\n\nexport function buildFtsQuery(query: string): string {\n  const tokens = query\n    .toLowerCase()\n    .split(/[\\s\\p{P}]+/u)\n    .filter(Boolean)\n    .filter((t) => t.length >= 2)\n    .filter((t) => !STOP_WORDS.has(t))\n    // Escape any double-quotes inside the token (FTS5 uses them as delimiters)\n    .map((t) => `\"${t.replace(/\"/g, '\"\"')}\"`)\n\n  if (tokens.length === 0) {\n    // Fallback: use original query as a raw string (may produce no results)\n    return `\"${query.replace(/\"/g, '\"\"')}\"`;\n  }\n\n  return tokens.join(\" OR \");\n}\n\n// ---------------------------------------------------------------------------\n// Search\n// ---------------------------------------------------------------------------\n\n/**\n * Search across all indexed memory using FTS5 BM25 ranking.\n *\n * Results are ordered by BM25 score (most relevant first).\n * FTS5 bm25() returns negative values; closer to 0 = more relevant.\n * We negate the score so callers get positive values where higher = better.\n *\n * Multilingual note: SQLite FTS5 uses the `unicode61` tokenizer by default,\n * which handles Unicode correctly (German umlauts, French accents, etc.) without\n * language-specific stemming. No changes needed here — it is already\n * multilingual-safe.\n */\nexport function searchMemory(\n  db: Database,\n  query: string,\n  opts?: SearchOptions,\n): SearchResult[] {\n  const maxResults = opts?.maxResults ?? 10;\n  const ftsQuery = buildFtsQuery(query);\n\n  // Build the SQL with optional filters\n  const conditions: string[] = [];\n  const params: (string | number)[] = [ftsQuery];\n\n  if (opts?.projectIds && opts.projectIds.length > 0) {\n    const placeholders = opts.projectIds.map(() => \"?\").join(\", \");\n    conditions.push(`c.project_id IN (${placeholders})`);\n    params.push(...opts.projectIds);\n  }\n\n  if (opts?.sources && opts.sources.length > 0) {\n    const placeholders = opts.sources.map(() => \"?\").join(\", \");\n    conditions.push(`c.source IN (${placeholders})`);\n    params.push(...opts.sources);\n  }\n\n  if (opts?.tiers && opts.tiers.length > 0) {\n    const placeholders = opts.tiers.map(() => \"?\").join(\", \");\n    conditions.push(`c.tier IN (${placeholders})`);\n    params.push(...opts.tiers);\n  }\n\n  const whereClause = conditions.length > 0\n    ? \"AND \" + conditions.join(\" AND \")\n    : \"\";\n\n  params.push(maxResults);\n\n  // FTS5: join memory_fts with memory_chunks to get metadata\n  // bm25(memory_fts) returns negative values (lower = better match)\n  const sql = `\n    SELECT\n      c.id,\n      c.project_id,\n      c.path,\n      c.start_line,\n      c.end_line,\n      c.text             AS snippet,\n      c.tier,\n      c.source,\n      c.updated_at,\n      c.last_accessed_at,\n      c.relevance_score,\n      bm25(memory_fts) AS bm25_score\n    FROM memory_fts\n    JOIN memory_chunks c ON memory_fts.id = c.id\n    WHERE memory_fts MATCH ?\n      ${whereClause}\n    ORDER BY bm25_score\n    LIMIT ?\n  `;\n\n  let rows: Array<{\n    id: string;\n    project_id: number;\n    path: string;\n    start_line: number;\n    end_line: number;\n    snippet: string;\n    tier: string;\n    source: string;\n    updated_at: number;\n    last_accessed_at: number | null;\n    relevance_score: number | null;\n    bm25_score: number;\n  }>;\n\n  try {\n    rows = db.prepare(sql).all(...params) as typeof rows;\n  } catch (e) {\n    // FTS5 MATCH throws on a malformed query, and for THAT an empty result is the\n    // honest answer — nothing matches a query that cannot be parsed.\n    //\n    // Everything else is a failure of the store: a missing table, a corrupt\n    // index, a locked database. Those used to return [] as well, which made an\n    // unusable index byte-identical to a genuine miss. The Postgres path had the\n    // same defect and it cost a real wrong answer on 2026-08-04 — the backend was\n    // down for two hours, every search reported \"No results found\", and a sibling\n    // session told the owner a DMARC note did not exist. See\n    // storage/postgres/search.ts.\n    if (!isQuerySyntaxError(e)) {\n      throw new Error(\n        `Memory keyword search failed — the index is unusable, so this is NOT an ` +\n          `empty result set. Cause: ${e instanceof Error ? e.message : String(e)}`\n      );\n    }\n    return [];\n  }\n\n  const minScore = opts?.minScore ?? 0.0;\n\n  return rows\n    .map((row) => {\n      // Negate so higher = better match for callers\n      const baseScore = -row.bm25_score;\n      // MR2: scale by feedback relevance_score: multiplier in [0.5, 1.5]\n      const relevanceScore = row.relevance_score ?? 0.5;\n      const score = baseScore * (0.5 + relevanceScore);\n      return {\n        chunkId: row.id,\n        projectId: row.project_id,\n        path: row.path,\n        startLine: row.start_line,\n        endLine: row.end_line,\n        snippet: row.snippet,\n        score,\n        tier: row.tier,\n        source: row.source,\n        updatedAt: row.updated_at,\n        lastAccessedAt: row.last_accessed_at ?? undefined,\n      };\n    })\n    .filter((r) => r.score >= minScore);\n}\n\n// ---------------------------------------------------------------------------\n// Semantic search\n// ---------------------------------------------------------------------------\n\n/**\n * Search chunks using brute-force cosine similarity over stored embeddings.\n *\n * Only chunks that have a non-null embedding BLOB are considered.  Chunks\n * without embeddings are silently skipped (they can be embedded later via\n * `embedChunks()`).\n *\n * @param queryEmbedding  Pre-computed Float32Array for the search query.\n */\nexport function searchMemorySemantic(\n  db: Database,\n  queryEmbedding: Float32Array,\n  opts?: SearchOptions,\n): SearchResult[] {\n  const maxResults = opts?.maxResults ?? 10;\n\n  // Build the SQL filter conditions\n  const conditions: string[] = [\"embedding IS NOT NULL\"];\n  const params: (string | number)[] = [];\n\n  if (opts?.projectIds && opts.projectIds.length > 0) {\n    const placeholders = opts.projectIds.map(() => \"?\").join(\", \");\n    conditions.push(`project_id IN (${placeholders})`);\n    params.push(...opts.projectIds);\n  }\n\n  if (opts?.sources && opts.sources.length > 0) {\n    const placeholders = opts.sources.map(() => \"?\").join(\", \");\n    conditions.push(`source IN (${placeholders})`);\n    params.push(...opts.sources);\n  }\n\n  if (opts?.tiers && opts.tiers.length > 0) {\n    const placeholders = opts.tiers.map(() => \"?\").join(\", \");\n    conditions.push(`tier IN (${placeholders})`);\n    params.push(...opts.tiers);\n  }\n\n  const where = \"WHERE \" + conditions.join(\" AND \");\n\n  // Hard cap for SQLite semantic path — prevents OOM on large corpora.\n  // Use Postgres for production semantic search.\n  const sql = `\n    SELECT id, project_id, path, start_line, end_line, text, tier, source, embedding, updated_at, last_accessed_at, relevance_score\n    FROM memory_chunks\n    ${where}\n    LIMIT 5000\n  `;\n\n  const rows = db.prepare(sql).all(...params) as Array<{\n    id: string;\n    project_id: number;\n    path: string;\n    start_line: number;\n    end_line: number;\n    text: string;\n    tier: string;\n    source: string;\n    embedding: Buffer;\n    updated_at: number;\n    last_accessed_at: number | null;\n    relevance_score: number | null;\n  }>;\n\n  if (rows.length === 0) return [];\n\n  // Compute cosine similarity for every chunk\n  const scored = rows.map((row) => {\n    const vec = deserializeEmbedding(row.embedding);\n    const baseScore = cosineSimilarity(queryEmbedding, vec);\n    // MR2: scale by feedback relevance_score: multiplier in [0.5, 1.5]\n    const relevanceScore = row.relevance_score ?? 0.5;\n    const score = baseScore * (0.5 + relevanceScore);\n    return {\n      chunkId: row.id,\n      projectId: row.project_id,\n      path: row.path,\n      startLine: row.start_line,\n      endLine: row.end_line,\n      snippet: row.text,\n      score,\n      tier: row.tier,\n      source: row.source,\n      updatedAt: row.updated_at,\n      lastAccessedAt: row.last_accessed_at ?? undefined,\n    };\n  });\n\n  // Sort by descending similarity, apply optional min score filter, limit\n  const minScore = opts?.minScore ?? -Infinity;\n\n  return scored\n    .filter((r) => r.score >= minScore)\n    .sort((a, b) => b.score - a.score)\n    .slice(0, maxResults);\n}\n\n// ---------------------------------------------------------------------------\n// Hybrid search\n// ---------------------------------------------------------------------------\n\n/**\n * Combine BM25 keyword search and semantic search using normalized scores.\n *\n * Both score sets are min-max normalized to [0,1] before combining, so neither\n * dominates the other regardless of their raw scales.\n *\n * @param queryEmbedding  Pre-computed embedding for the query.\n * @param keywordWeight   Weight for BM25 score (default 0.5).\n * @param semanticWeight  Weight for cosine similarity score (default 0.5).\n */\nexport function searchMemoryHybrid(\n  db: Database,\n  query: string,\n  queryEmbedding: Float32Array,\n  opts?: SearchOptions & { keywordWeight?: number; semanticWeight?: number },\n): SearchResult[] {\n  const maxResults = opts?.maxResults ?? 10;\n  const kw = opts?.keywordWeight ?? 0.5;\n  const sw = opts?.semanticWeight ?? 0.5;\n\n  // Fetch keyword results — 50 candidates is sufficient for min-max normalization\n  const keywordResults = searchMemory(db, query, {\n    ...opts,\n    maxResults: 50,\n  });\n\n  // Fetch semantic results — 50 candidates is sufficient for min-max normalization\n  const semanticResults = searchMemorySemantic(db, queryEmbedding, {\n    ...opts,\n    maxResults: 50,\n  });\n\n  if (keywordResults.length === 0 && semanticResults.length === 0) return [];\n\n  // Build a map of chunk ID → combined result\n  // Use \"projectId:path:startLine:endLine\" as a stable key (same as chunk IDs)\n  const keyFor = (r: SearchResult) =>\n    `${r.projectId}:${r.path}:${r.startLine}:${r.endLine}`;\n\n  // Min-max normalize helper\n  function minMaxNormalize(items: SearchResult[]): Map<string, number> {\n    if (items.length === 0) return new Map();\n    const min = Math.min(...items.map((r) => r.score));\n    const max = Math.max(...items.map((r) => r.score));\n    const range = max - min;\n    const m = new Map<string, number>();\n    for (const r of items) {\n      m.set(keyFor(r), range === 0 ? 1 : (r.score - min) / range);\n    }\n    return m;\n  }\n\n  const kwNorm = minMaxNormalize(keywordResults);\n  const semNorm = minMaxNormalize(semanticResults);\n\n  // Union of all chunk keys\n  const allKeys = new Set<string>([\n    ...keywordResults.map(keyFor),\n    ...semanticResults.map(keyFor),\n  ]);\n\n  // Build a lookup from key → result metadata\n  const metaMap = new Map<string, SearchResult>();\n  for (const r of [...keywordResults, ...semanticResults]) {\n    metaMap.set(keyFor(r), r);\n  }\n\n  // Combine scores\n  const combined: Array<SearchResult & { combinedScore: number }> = [];\n  for (const key of allKeys) {\n    const meta = metaMap.get(key)!;\n    const kwScore = kwNorm.get(key) ?? 0;\n    const semScore = semNorm.get(key) ?? 0;\n    const combinedScore = kw * kwScore + sw * semScore;\n    combined.push({ ...meta, score: combinedScore, combinedScore });\n  }\n\n  // Sort by combined score descending\n  return combined\n    .sort((a, b) => b.score - a.score)\n    .slice(0, maxResults)\n    .map(({ combinedScore: _unused, ...r }) => r);\n}\n\n// ---------------------------------------------------------------------------\n// Access timestamp tracking (QW2)\n// ---------------------------------------------------------------------------\n\n/**\n * Update last_accessed_at for a set of chunk IDs to the current timestamp.\n *\n * Called after a successful search to record that these chunks were retrieved.\n * This enables the recency boost to account for access patterns, not just\n * modification time.\n *\n * Best-effort: errors are silently ignored so search is never blocked.\n */\nexport function touchChunksLastAccessed(db: Database, chunkIds: string[]): void {\n  if (chunkIds.length === 0) return;\n  try {\n    const now = Date.now();\n    const placeholders = chunkIds.map(() => \"?\").join(\", \");\n    db.prepare(\n      `UPDATE memory_chunks SET last_accessed_at = ? WHERE id IN (${placeholders})`\n    ).run(now, ...chunkIds);\n  } catch {\n    // non-critical — do not block search results\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Slug lookup helper\n// ---------------------------------------------------------------------------\n\n/**\n * Populate the projectSlug field on search results by looking up project IDs\n * in the registry database.\n */\nexport function populateSlugs(\n  results: SearchResult[],\n  registryDb: Database,\n): SearchResult[] {\n  if (results.length === 0) return results;\n\n  const ids = [...new Set(results.map((r) => r.projectId))];\n  const placeholders = ids.map(() => \"?\").join(\", \");\n  const rows = registryDb\n    .prepare(`SELECT id, slug FROM projects WHERE id IN (${placeholders})`)\n    .all(...ids) as Array<{ id: number; slug: string }>;\n\n  const slugMap = new Map(rows.map((r) => [r.id, r.slug]));\n\n  return results.map((r) => ({\n    ...r,\n    projectSlug: slugMap.get(r.projectId),\n  }));\n}\n\n// ---------------------------------------------------------------------------\n// Recency boost\n// ---------------------------------------------------------------------------\n\n/**\n * Apply exponential recency boost to search scores.\n *\n * Scores are first min-max normalized to [0,1], then multiplied by an\n * exponential decay factor based on chunk age.  Normalization is required\n * because the cross-encoder reranker produces negative logit scores — naive\n * multiplication of a negative score by a decay factor (0 < d ≤ 1) would\n * make the score *less* negative, effectively boosting old results instead\n * of penalizing them.\n *\n * Formula: score_final = normalized * exp(-lambda * age_days)\n * where lambda = ln(2) / halfLifeDays, normalized ∈ [0,1]\n *\n * With default halfLifeDays=90, a 3-month-old chunk retains 50% of its\n * normalized score, a 6-month-old retains 25%, and a 1-year-old ~6%.\n *\n * Results without an updatedAt timestamp receive no decay penalty.\n * Results are re-sorted by the boosted score after application.\n *\n * @param results      Search results with optional updatedAt timestamps.\n * @param halfLifeDays Score halves every N days. Default 90 (~3 months).\n * @returns New array sorted by decayed normalized score (descending).\n */\nexport function applyRecencyBoost(\n  results: SearchResult[],\n  halfLifeDays = 90,\n): SearchResult[] {\n  if (halfLifeDays <= 0 || results.length === 0) return results;\n\n  const lambda = Math.LN2 / halfLifeDays;\n  const now = Date.now();\n\n  // Min-max normalize scores to [0,1] so multiplicative decay works\n  // correctly regardless of the raw score sign/scale.\n  const scores = results.map((r) => r.score);\n  const minScore = Math.min(...scores);\n  const maxScore = Math.max(...scores);\n  const range = maxScore - minScore;\n\n  return results\n    .map((r) => {\n      const normalized = range === 0 ? 1 : (r.score - minScore) / range;\n      // QW2: use the more recent of updated_at and last_accessed_at for recency decay\n      const effectiveTs = r.updatedAt != null && r.lastAccessedAt != null\n        ? Math.max(r.updatedAt, r.lastAccessedAt)\n        : (r.lastAccessedAt ?? r.updatedAt);\n      const decay = effectiveTs\n        ? Math.exp(-lambda * Math.max(0, (now - effectiveTs) / 86_400_000))\n        : 1; // no timestamp → no penalty\n      return { ...r, score: normalized * decay };\n    })\n    .sort((a, b) => b.score - a.score);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFA,SAAgB,mBAAmB,GAAqB;CACtD,MAAM,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;AACtD,QAAO,wEAAwE,KAAK,IAAI;;AAG1F,SAAgB,cAAc,OAAuB;CACnD,MAAM,SAAS,MACZ,aAAa,CACb,MAAM,cAAc,CACpB,OAAO,QAAQ,CACf,QAAQ,MAAM,EAAE,UAAU,EAAE,CAC5B,QAAQ,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,CAEjC,KAAK,MAAM,IAAI,EAAE,QAAQ,MAAM,OAAK,CAAC,GAAG;AAE3C,KAAI,OAAO,WAAW,EAEpB,QAAO,IAAI,MAAM,QAAQ,MAAM,OAAK,CAAC;AAGvC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;AAmB5B,SAAgB,aACd,IACA,OACA,MACgB;CAChB,MAAM,aAAa,MAAM,cAAc;CACvC,MAAM,WAAW,cAAc,MAAM;CAGrC,MAAM,aAAuB,EAAE;CAC/B,MAAM,SAA8B,CAAC,SAAS;AAE9C,KAAI,MAAM,cAAc,KAAK,WAAW,SAAS,GAAG;EAClD,MAAM,eAAe,KAAK,WAAW,UAAU,IAAI,CAAC,KAAK,KAAK;AAC9D,aAAW,KAAK,oBAAoB,aAAa,GAAG;AACpD,SAAO,KAAK,GAAG,KAAK,WAAW;;AAGjC,KAAI,MAAM,WAAW,KAAK,QAAQ,SAAS,GAAG;EAC5C,MAAM,eAAe,KAAK,QAAQ,UAAU,IAAI,CAAC,KAAK,KAAK;AAC3D,aAAW,KAAK,gBAAgB,aAAa,GAAG;AAChD,SAAO,KAAK,GAAG,KAAK,QAAQ;;AAG9B,KAAI,MAAM,SAAS,KAAK,MAAM,SAAS,GAAG;EACxC,MAAM,eAAe,KAAK,MAAM,UAAU,IAAI,CAAC,KAAK,KAAK;AACzD,aAAW,KAAK,cAAc,aAAa,GAAG;AAC9C,SAAO,KAAK,GAAG,KAAK,MAAM;;CAG5B,MAAM,cAAc,WAAW,SAAS,IACpC,SAAS,WAAW,KAAK,QAAQ,GACjC;AAEJ,QAAO,KAAK,WAAW;CAIvB,MAAM,MAAM;;;;;;;;;;;;;;;;;QAiBN,YAAY;;;;CAKlB,IAAI;AAeJ,KAAI;AACF,SAAO,GAAG,QAAQ,IAAI,CAAC,IAAI,GAAG,OAAO;UAC9B,GAAG;AAWV,MAAI,CAAC,mBAAmB,EAAE,CACxB,OAAM,IAAI,MACR,oGAC8B,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE,GACzE;AAEH,SAAO,EAAE;;CAGX,MAAM,WAAW,MAAM,YAAY;AAEnC,QAAO,KACJ,KAAK,QAAQ;EAKZ,MAAM,QAHY,CAAC,IAAI,cAGI,MADJ,IAAI,mBAAmB;AAE9C,SAAO;GACL,SAAS,IAAI;GACb,WAAW,IAAI;GACf,MAAM,IAAI;GACV,WAAW,IAAI;GACf,SAAS,IAAI;GACb,SAAS,IAAI;GACb;GACA,MAAM,IAAI;GACV,QAAQ,IAAI;GACZ,WAAW,IAAI;GACf,gBAAgB,IAAI,oBAAoB;GACzC;GACD,CACD,QAAQ,MAAM,EAAE,SAAS,SAAS;;;;;;;;;;;AAgBvC,SAAgB,qBACd,IACA,gBACA,MACgB;CAChB,MAAM,aAAa,MAAM,cAAc;CAGvC,MAAM,aAAuB,CAAC,wBAAwB;CACtD,MAAM,SAA8B,EAAE;AAEtC,KAAI,MAAM,cAAc,KAAK,WAAW,SAAS,GAAG;EAClD,MAAM,eAAe,KAAK,WAAW,UAAU,IAAI,CAAC,KAAK,KAAK;AAC9D,aAAW,KAAK,kBAAkB,aAAa,GAAG;AAClD,SAAO,KAAK,GAAG,KAAK,WAAW;;AAGjC,KAAI,MAAM,WAAW,KAAK,QAAQ,SAAS,GAAG;EAC5C,MAAM,eAAe,KAAK,QAAQ,UAAU,IAAI,CAAC,KAAK,KAAK;AAC3D,aAAW,KAAK,cAAc,aAAa,GAAG;AAC9C,SAAO,KAAK,GAAG,KAAK,QAAQ;;AAG9B,KAAI,MAAM,SAAS,KAAK,MAAM,SAAS,GAAG;EACxC,MAAM,eAAe,KAAK,MAAM,UAAU,IAAI,CAAC,KAAK,KAAK;AACzD,aAAW,KAAK,YAAY,aAAa,GAAG;AAC5C,SAAO,KAAK,GAAG,KAAK,MAAM;;CAO5B,MAAM,MAAM;;;MAJE,WAAW,WAAW,KAAK,QAAQ,CAOvC;;;CAIV,MAAM,OAAO,GAAG,QAAQ,IAAI,CAAC,IAAI,GAAG,OAAO;AAe3C,KAAI,KAAK,WAAW,EAAG,QAAO,EAAE;CAGhC,MAAM,SAAS,KAAK,KAAK,QAAQ;EAK/B,MAAM,QAHY,iBAAiB,gBADvB,qBAAqB,IAAI,UAAU,CACQ,IAG5B,MADJ,IAAI,mBAAmB;AAE9C,SAAO;GACL,SAAS,IAAI;GACb,WAAW,IAAI;GACf,MAAM,IAAI;GACV,WAAW,IAAI;GACf,SAAS,IAAI;GACb,SAAS,IAAI;GACb;GACA,MAAM,IAAI;GACV,QAAQ,IAAI;GACZ,WAAW,IAAI;GACf,gBAAgB,IAAI,oBAAoB;GACzC;GACD;CAGF,MAAM,WAAW,MAAM,YAAY;AAEnC,QAAO,OACJ,QAAQ,MAAM,EAAE,SAAS,SAAS,CAClC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,CACjC,MAAM,GAAG,WAAW;;;;;;;;;;;;AAiBzB,SAAgB,mBACd,IACA,OACA,gBACA,MACgB;CAChB,MAAM,aAAa,MAAM,cAAc;CACvC,MAAM,KAAK,MAAM,iBAAiB;CAClC,MAAM,KAAK,MAAM,kBAAkB;CAGnC,MAAM,iBAAiB,aAAa,IAAI,OAAO;EAC7C,GAAG;EACH,YAAY;EACb,CAAC;CAGF,MAAM,kBAAkB,qBAAqB,IAAI,gBAAgB;EAC/D,GAAG;EACH,YAAY;EACb,CAAC;AAEF,KAAI,eAAe,WAAW,KAAK,gBAAgB,WAAW,EAAG,QAAO,EAAE;CAI1E,MAAM,UAAU,MACd,GAAG,EAAE,UAAU,GAAG,EAAE,KAAK,GAAG,EAAE,UAAU,GAAG,EAAE;CAG/C,SAAS,gBAAgB,OAA4C;AACnE,MAAI,MAAM,WAAW,EAAG,wBAAO,IAAI,KAAK;EACxC,MAAM,MAAM,KAAK,IAAI,GAAG,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;EAElD,MAAM,QADM,KAAK,IAAI,GAAG,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC,GAC9B;EACpB,MAAM,oBAAI,IAAI,KAAqB;AACnC,OAAK,MAAM,KAAK,MACd,GAAE,IAAI,OAAO,EAAE,EAAE,UAAU,IAAI,KAAK,EAAE,QAAQ,OAAO,MAAM;AAE7D,SAAO;;CAGT,MAAM,SAAS,gBAAgB,eAAe;CAC9C,MAAM,UAAU,gBAAgB,gBAAgB;CAGhD,MAAM,UAAU,IAAI,IAAY,CAC9B,GAAG,eAAe,IAAI,OAAO,EAC7B,GAAG,gBAAgB,IAAI,OAAO,CAC/B,CAAC;CAGF,MAAM,0BAAU,IAAI,KAA2B;AAC/C,MAAK,MAAM,KAAK,CAAC,GAAG,gBAAgB,GAAG,gBAAgB,CACrD,SAAQ,IAAI,OAAO,EAAE,EAAE,EAAE;CAI3B,MAAM,WAA4D,EAAE;AACpE,MAAK,MAAM,OAAO,SAAS;EACzB,MAAM,OAAO,QAAQ,IAAI,IAAI;EAC7B,MAAM,UAAU,OAAO,IAAI,IAAI,IAAI;EACnC,MAAM,WAAW,QAAQ,IAAI,IAAI,IAAI;EACrC,MAAM,gBAAgB,KAAK,UAAU,KAAK;AAC1C,WAAS,KAAK;GAAE,GAAG;GAAM,OAAO;GAAe;GAAe,CAAC;;AAIjE,QAAO,SACJ,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,CACjC,MAAM,GAAG,WAAW,CACpB,KAAK,EAAE,eAAe,SAAS,GAAG,QAAQ,EAAE;;;;;;;;;;;AAgBjD,SAAgB,wBAAwB,IAAc,UAA0B;AAC9E,KAAI,SAAS,WAAW,EAAG;AAC3B,KAAI;EACF,MAAM,MAAM,KAAK,KAAK;EACtB,MAAM,eAAe,SAAS,UAAU,IAAI,CAAC,KAAK,KAAK;AACvD,KAAG,QACD,8DAA8D,aAAa,GAC5E,CAAC,IAAI,KAAK,GAAG,SAAS;SACjB;;;;;;AAaV,SAAgB,cACd,SACA,YACgB;AAChB,KAAI,QAAQ,WAAW,EAAG,QAAO;CAEjC,MAAM,MAAM,CAAC,GAAG,IAAI,IAAI,QAAQ,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC;CACzD,MAAM,eAAe,IAAI,UAAU,IAAI,CAAC,KAAK,KAAK;CAClD,MAAM,OAAO,WACV,QAAQ,8CAA8C,aAAa,GAAG,CACtE,IAAI,GAAG,IAAI;CAEd,MAAM,UAAU,IAAI,IAAI,KAAK,KAAK,MAAM,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;AAExD,QAAO,QAAQ,KAAK,OAAO;EACzB,GAAG;EACH,aAAa,QAAQ,IAAI,EAAE,UAAU;EACtC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;AA8BL,SAAgB,kBACd,SACA,eAAe,IACC;AAChB,KAAI,gBAAgB,KAAK,QAAQ,WAAW,EAAG,QAAO;CAEtD,MAAM,SAAS,KAAK,MAAM;CAC1B,MAAM,MAAM,KAAK,KAAK;CAItB,MAAM,SAAS,QAAQ,KAAK,MAAM,EAAE,MAAM;CAC1C,MAAM,WAAW,KAAK,IAAI,GAAG,OAAO;CAEpC,MAAM,QADW,KAAK,IAAI,GAAG,OAAO,GACX;AAEzB,QAAO,QACJ,KAAK,MAAM;EACV,MAAM,aAAa,UAAU,IAAI,KAAK,EAAE,QAAQ,YAAY;EAE5D,MAAM,cAAc,EAAE,aAAa,QAAQ,EAAE,kBAAkB,OAC3D,KAAK,IAAI,EAAE,WAAW,EAAE,eAAe,GACtC,EAAE,kBAAkB,EAAE;EAC3B,MAAM,QAAQ,cACV,KAAK,IAAI,CAAC,SAAS,KAAK,IAAI,IAAI,MAAM,eAAe,MAAW,CAAC,GACjE;AACJ,SAAO;GAAE,GAAG;GAAG,OAAO,aAAa;GAAO;GAC1C,CACD,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM"}