{"version":3,"file":"clusters-Cw_P6lO-.mjs","names":[],"sources":["../src/graph/clusters.ts"],"sourcesContent":["/**\n * clusters.ts — graph_clusters endpoint handler\n *\n * Reuses the zettelThemes() agglomerative clustering algorithm and enriches\n * each cluster with observation-type statistics, avg_recency from member\n * timestamps, and helper flags for the Obsidian knowledge plugin.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { Pool } from \"pg\";\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphClustersParams {\n  project_id?: number;\n  min_size?: number;\n  max_clusters?: number;\n  lookback_days?: number;\n  similarity_threshold?: number;\n}\n\nexport interface ClusterNode {\n  id: number;\n  label: string;\n  size: number;\n  folder_diversity: number;\n  avg_recency: number;\n  linked_ratio: number;\n  dominant_observation_type: string;\n  observation_type_counts: Record<string, number>;\n  suggest_index_note: boolean;\n  has_idea_note: boolean;\n  notes: Array<{ vault_path: string; title: string; indexed_at: number }>;\n}\n\nexport interface GraphClustersResult {\n  clusters: ClusterNode[];\n  total_notes_analyzed: number;\n  time_window: { from: number; to: number };\n}\n\n// ---------------------------------------------------------------------------\n// Observation type enrichment\n// ---------------------------------------------------------------------------\n\n/**\n * Query pai_observations (Postgres) for observation types associated with\n * the given file paths. Returns a map from vault_path → type counts.\n *\n * Falls back to an empty map when the pool is not available or the query fails.\n */\nasync function fetchObservationTypes(\n  pool: Pool,\n  filePaths: string[],\n  projectId?: number\n): Promise<Map<string, Record<string, number>>> {\n  if (filePaths.length === 0) return new Map();\n\n  try {\n    const params: (string | number)[] = [...filePaths];\n    let projectFilter = \"\";\n    if (projectId !== undefined) {\n      params.push(projectId);\n      projectFilter = `AND project_id = $${params.length}`;\n    }\n\n    const result = await pool.query<{ path: string; type: string; cnt: string }>(\n      `SELECT unnested_path AS path, type, COUNT(*) AS cnt\n       FROM pai_observations,\n            LATERAL unnest(files_modified || files_read) AS unnested_path\n       WHERE unnested_path = ANY($1::text[])\n         ${projectFilter}\n       GROUP BY unnested_path, type`,\n      [filePaths, ...params.slice(filePaths.length)]\n    );\n\n    const byPath = new Map<string, Record<string, number>>();\n    for (const row of result.rows) {\n      const existing = byPath.get(row.path) ?? {};\n      existing[row.type] = (existing[row.type] ?? 0) + parseInt(row.cnt, 10);\n      byPath.set(row.path, existing);\n    }\n    return byPath;\n  } catch {\n    return new Map();\n  }\n}\n\n/**\n * Aggregate per-path observation type counts into cluster-level counts,\n * then pick the dominant type.\n */\nfunction aggregateObservationTypes(\n  paths: string[],\n  byPath: Map<string, Record<string, number>>\n): { dominant: string; counts: Record<string, number> } {\n  const counts: Record<string, number> = {};\n  for (const path of paths) {\n    const pathCounts = byPath.get(path);\n    if (!pathCounts) continue;\n    for (const [type, n] of Object.entries(pathCounts)) {\n      counts[type] = (counts[type] ?? 0) + n;\n    }\n  }\n\n  let dominant = \"unknown\";\n  let maxCount = 0;\n  for (const [type, n] of Object.entries(counts)) {\n    if (n > maxCount) {\n      maxCount = n;\n      dominant = type;\n    }\n  }\n\n  return { dominant, counts };\n}\n\n// ---------------------------------------------------------------------------\n// Link-based fallback clustering (wikilink connected components)\n// ---------------------------------------------------------------------------\n\nconst SKIP_PREFIXES = [\n  \"Attachments/\", \"🗓️ Daily Notes/\", \"Copilot/copilot-conversations/\",\n  \"Z - Zettelkasten/Tweets/\",\n];\n\n/**\n * Cluster vault notes by wikilink connectivity when embeddings aren't available.\n * Uses BFS to find connected components in the link graph, then picks the\n * largest components as clusters. Labels are derived from the most common\n * title words in each component.\n */\nasync function clusterByLinks(\n  backend: StorageBackend,\n  lookbackDays: number,\n  minSize: number,\n  maxClusters: number,\n): Promise<{ themes: Array<{ id: number; label: string; notes: Array<{ path: string; title: string | null }>; size: number; folderDiversity: number; avgRecency: number; linkedRatio: number; suggestIndexNote: boolean }>; totalNotesAnalyzed: number; timeWindow: { from: number; to: number } }> {\n  const now = Date.now();\n  const from = now - lookbackDays * 86400000;\n\n  // Get recent notes\n  const recentFiles = await backend.getRecentVaultFiles(from);\n  const recentNotes = recentFiles.filter(f => f.vaultPath.endsWith(\".md\"));\n\n  const noteMap = new Map<string, { title: string | null; indexed_at: number }>();\n  for (const n of recentNotes) {\n    noteMap.set(n.vaultPath, { title: n.title, indexed_at: n.indexedAt });\n  }\n\n  // Build adjacency list from vault_links (only for recent notes)\n  const adj = new Map<string, Set<string>>();\n  for (const path of noteMap.keys()) {\n    if (!adj.has(path)) adj.set(path, new Set());\n  }\n\n  const linkGraph = await backend.getVaultLinkGraph();\n\n  for (const { source_path, target_path } of linkGraph) {\n    if (noteMap.has(source_path) && noteMap.has(target_path)) {\n      adj.get(source_path)!.add(target_path);\n      adj.get(target_path)!.add(source_path);\n    }\n  }\n\n  // Remove hub nodes before BFS\n  const degrees = [...adj.entries()].map(([p, s]) => ({ path: p, degree: s.size }));\n  degrees.sort((a, b) => b.degree - a.degree);\n  const hubThreshold = Math.max(10, degrees[Math.floor(degrees.length * 0.05)]?.degree ?? 10);\n  const hubNodes = new Set<string>();\n  for (const { path, degree } of degrees) {\n    if (degree >= hubThreshold) hubNodes.add(path);\n    else break;\n  }\n\n  for (const hub of hubNodes) {\n    adj.delete(hub);\n  }\n  for (const [, neighbors] of adj) {\n    for (const hub of hubNodes) {\n      neighbors.delete(hub);\n    }\n  }\n\n  // BFS connected components\n  const visited = new Set<string>();\n  const components: string[][] = [];\n\n  for (const path of noteMap.keys()) {\n    if (visited.has(path) || hubNodes.has(path)) continue;\n    if (SKIP_PREFIXES.some(p => path.startsWith(p))) { visited.add(path); continue; }\n    const component: string[] = [];\n    const queue = [path];\n    visited.add(path);\n\n    while (queue.length > 0) {\n      const current = queue.shift()!;\n      component.push(current);\n      const neighbors = adj.get(current);\n      if (!neighbors) continue;\n      for (const neighbor of neighbors) {\n        if (!visited.has(neighbor) && !SKIP_PREFIXES.some(p => neighbor.startsWith(p))) {\n          visited.add(neighbor);\n          queue.push(neighbor);\n        }\n      }\n    }\n\n    if (component.length >= minSize) {\n      components.push(component);\n    }\n  }\n\n  components.sort((a, b) => b.length - a.length);\n  const topComponents = components.slice(0, maxClusters);\n\n  // STOP_WORDS imported from utils/stop-words.ts (module-level import)\n\n  function generateLinkLabel(paths: string[]): string {\n    const wordCounts = new Map<string, number>();\n    for (const p of paths) {\n      const title = noteMap.get(p)?.title;\n      if (!title) continue;\n      const words = title.toLowerCase().replace(/[^a-z0-9äöüàéèêëçñß\\s]/g, \" \").split(/\\s+/)\n        .filter(w => w.length > 2 && !STOP_WORDS.has(w));\n      for (const word of words) {\n        wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);\n      }\n    }\n    const sorted = [...wordCounts.entries()].sort((a, b) => b[1] - a[1]);\n    return sorted.slice(0, 3).map(([w]) => w).join(\" / \") || \"Linked Notes\";\n  }\n\n  const themes = topComponents.map((component, idx) => {\n    const notes = component.map(p => ({\n      path: p,\n      title: noteMap.get(p)?.title ?? null,\n    }));\n    const avgRecency = component.reduce((sum, p) => sum + (noteMap.get(p)?.indexed_at ?? 0), 0) / component.length;\n    const uniqueFolders = new Set(component.map(p => p.split(\"/\")[0]));\n\n    return {\n      id: idx,\n      label: generateLinkLabel(component),\n      notes,\n      size: component.length,\n      folderDiversity: uniqueFolders.size / component.length,\n      avgRecency,\n      linkedRatio: 1.0,\n      suggestIndexNote: component.length >= 10,\n    };\n  });\n\n  return {\n    themes,\n    totalNotesAnalyzed: recentNotes.length,\n    timeWindow: { from, to: now },\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Main handler\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphClusters(\n  pool: Pool | null,\n  backend: StorageBackend,\n  params: GraphClustersParams\n): Promise<GraphClustersResult> {\n  const minSize = params.min_size ?? 3;\n  const maxClusters = params.max_clusters ?? 20;\n  const lookbackDays = params.lookback_days ?? 90;\n\n  const vaultProjectId = params.project_id ?? 0;\n\n  if (!vaultProjectId) {\n    throw new Error(\n      \"graph_clusters: project_id is required (pass the vault project's numeric ID)\"\n    );\n  }\n\n  const themeResult = await clusterByLinks(backend, lookbackDays, minSize, maxClusters);\n\n  const allPaths = themeResult.themes.flatMap((t) => t.notes.map((n) => n.path));\n\n  const observationsByPath =\n    pool !== null\n      ? await fetchObservationTypes(pool, allPaths, params.project_id)\n      : new Map<string, Record<string, number>>();\n\n  // Fetch indexed_at timestamps for all notes in bulk\n  const fileRows = await backend.getVaultFilesByPaths(allPaths);\n  const indexedAtMap = new Map<string, number>(fileRows.map(f => [f.vaultPath, f.indexedAt]));\n\n  const clusters: ClusterNode[] = themeResult.themes.map((theme) => {\n    const notePaths = theme.notes.map((n) => n.path);\n\n    const notesWithTimestamps = theme.notes.map((n) => ({\n      vault_path: n.path,\n      title: n.title ?? n.path.split(\"/\").pop() ?? n.path,\n      indexed_at: indexedAtMap.get(n.path) ?? 0,\n    }));\n\n    const avgRecency = theme.avgRecency;\n\n    const { dominant, counts } = aggregateObservationTypes(\n      notePaths,\n      observationsByPath\n    );\n\n    return {\n      id: theme.id,\n      label: theme.label,\n      size: theme.size,\n      folder_diversity: theme.folderDiversity,\n      avg_recency: avgRecency,\n      linked_ratio: theme.linkedRatio,\n      dominant_observation_type: dominant,\n      observation_type_counts: counts,\n      suggest_index_note: theme.suggestIndexNote,\n      has_idea_note: false,\n      notes: notesWithTimestamps,\n    };\n  });\n\n  clusters.sort((a, b) => b.size - a.size);\n\n  return {\n    clusters: clusters.slice(0, maxClusters),\n    total_notes_analyzed: themeResult.totalNotesAnalyzed,\n    time_window: themeResult.timeWindow,\n  };\n}\n"],"mappings":"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"}