{"version":3,"file":"themes-BVoAnLZj.mjs","names":[],"sources":["../src/zettelkasten/themes.ts"],"sourcesContent":["import type { StorageBackend } from \"../storage/interface.js\";\nimport { deserializeEmbedding, cosineSimilarity } from \"../memory/embeddings.js\";\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\n\nexport interface ThemeOptions {\n  vaultProjectId: number;\n  lookbackDays?: number;\n  minClusterSize?: number;\n  maxThemes?: number;\n  similarityThreshold?: number;\n}\n\nexport interface ThemeCluster {\n  id: number;\n  label: string;\n  notes: Array<{\n    path: string;\n    title: string | null;\n  }>;\n  size: number;\n  folderDiversity: number;\n  avgRecency: number;\n  linkedRatio: number;\n  suggestIndexNote: boolean;\n}\n\nexport interface ThemeResult {\n  themes: ThemeCluster[];\n  totalNotesAnalyzed: number;\n  timeWindow: { from: number; to: number };\n}\n\nconst MAX_CHUNKS = 5000;\n\n// STOP_WORDS imported from utils/stop-words.ts\n\nfunction getTopFolder(vaultPath: string): string {\n  const parts = vaultPath.split(\"/\");\n  return parts.length > 1 ? parts[0] : \"\";\n}\n\nfunction generateLabel(titles: Array<string | null>): string {\n  const wordCounts = new Map<string, number>();\n  for (const title of titles) {\n    if (!title) continue;\n    const words = title\n      .toLowerCase()\n      .replace(/[^a-z0-9\\s]/g, \" \")\n      .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\n    .slice(0, 3)\n    .map(([w]) => w)\n    .join(\" / \");\n}\n\nasync function computeLinkedRatio(backend: StorageBackend, paths: string[]): Promise<number> {\n  if (paths.length < 2) return 0;\n  const totalPairs = (paths.length * (paths.length - 1)) / 2;\n  const pathSet = new Set(paths);\n  let linkedPairs = 0;\n\n  for (const path of paths) {\n    const links = await backend.getLinksFromSource(path);\n    for (const link of links) {\n      if (link.targetPath && pathSet.has(link.targetPath)) {\n        linkedPairs++;\n      }\n    }\n  }\n\n  // Each bidirectional pair might be counted once per direction; divide by 2 to normalize\n  const uniquePairs = linkedPairs / 2;\n  return Math.min(1, uniquePairs / totalPairs);\n}\n\ntype ClusterNode = {\n  paths: string[];\n  titles: Array<string | null>;\n  indexedAts: number[];\n  centroid: Float32Array;\n};\n\nfunction averageEmbeddings(embeddings: Float32Array[]): Float32Array {\n  if (embeddings.length === 0) return new Float32Array(0);\n  const dim = embeddings[0].length;\n  const sum = new Float32Array(dim);\n  for (const vec of embeddings) {\n    for (let i = 0; i < dim; i++) {\n      sum[i] += vec[i];\n    }\n  }\n  const avg = new Float32Array(dim);\n  for (let i = 0; i < dim; i++) {\n    avg[i] = sum[i] / embeddings.length;\n  }\n  return avg;\n}\n\n/**\n * Detect emerging themes in recently-modified notes using agglomerative single-linkage\n * clustering of note-level embeddings.\n */\nexport async function zettelThemes(\n  backend: StorageBackend,\n  opts: ThemeOptions,\n): Promise<ThemeResult> {\n  const lookbackDays = opts.lookbackDays ?? 30;\n  const minClusterSize = opts.minClusterSize ?? 3;\n  const maxThemes = opts.maxThemes ?? 10;\n  const similarityThreshold = opts.similarityThreshold ?? 0.65;\n\n  const now = Date.now();\n  const from = now - lookbackDays * 86400000;\n\n  // Step 1: get recent notes\n  const recentFiles = await backend.getRecentVaultFiles(from);\n  const recentNotes = recentFiles.map(f => ({ vault_path: f.vaultPath, title: f.title, indexed_at: f.indexedAt }));\n\n  // Step 2: get file-level embeddings from memory_chunks\n  const chunkRows = await backend.getChunksWithEmbeddings(opts.vaultProjectId, MAX_CHUNKS);\n\n  const embeddingsByPath = new Map<string, Float32Array[]>();\n  for (const row of chunkRows) {\n    const vec = deserializeEmbedding(row.embedding);\n    const arr = embeddingsByPath.get(row.path);\n    if (!arr) {\n      embeddingsByPath.set(row.path, [vec]);\n    } else {\n      arr.push(vec);\n    }\n  }\n\n  const fileEmbeddings = new Map<string, Float32Array>();\n  for (const [path, vecs] of embeddingsByPath) {\n    fileEmbeddings.set(path, averageEmbeddings(vecs));\n  }\n\n  // Step 3: build initial clusters — only include notes that have embeddings\n  const clusters: ClusterNode[] = [];\n  for (const note of recentNotes) {\n    const embedding = fileEmbeddings.get(note.vault_path);\n    if (!embedding) continue;\n    clusters.push({\n      paths: [note.vault_path],\n      titles: [note.title],\n      indexedAts: [note.indexed_at],\n      centroid: embedding,\n    });\n  }\n\n  const totalNotesAnalyzed = clusters.length;\n\n  // Step 4: agglomerative single-linkage clustering\n  // Stop when no two clusters have similarity >= threshold\n  let merged = true;\n  while (merged && clusters.length > 1) {\n    merged = false;\n    let bestSim = similarityThreshold;\n    let bestI = -1;\n    let bestJ = -1;\n\n    for (let i = 0; i < clusters.length; i++) {\n      for (let j = i + 1; j < clusters.length; j++) {\n        const sim = cosineSimilarity(clusters[i].centroid, clusters[j].centroid);\n        if (sim > bestSim) {\n          bestSim = sim;\n          bestI = i;\n          bestJ = j;\n        }\n      }\n    }\n\n    if (bestI === -1) break;\n\n    // Merge cluster j into cluster i\n    const ci = clusters[bestI];\n    const cj = clusters[bestJ];\n    const mergedPaths = [...ci.paths, ...cj.paths];\n    const mergedTitles = [...ci.titles, ...cj.titles];\n    const mergedIndexedAts = [...ci.indexedAts, ...cj.indexedAts];\n\n    // Recompute centroid from averaged embeddings of all member paths\n    const memberEmbeddings: Float32Array[] = [];\n    for (const p of mergedPaths) {\n      const emb = fileEmbeddings.get(p);\n      if (emb) memberEmbeddings.push(emb);\n    }\n\n    clusters[bestI] = {\n      paths: mergedPaths,\n      titles: mergedTitles,\n      indexedAts: mergedIndexedAts,\n      centroid: averageEmbeddings(memberEmbeddings),\n    };\n\n    clusters.splice(bestJ, 1);\n    merged = true;\n  }\n\n  // Step 5: filter and annotate clusters\n  const themes: ThemeCluster[] = [];\n  let clusterIndex = 0;\n\n  for (const cluster of clusters) {\n    if (cluster.paths.length < minClusterSize) continue;\n\n    const label = generateLabel(cluster.titles) || `Theme ${clusterIndex + 1}`;\n    const avgRecency =\n      cluster.indexedAts.reduce((sum, t) => sum + t, 0) / cluster.indexedAts.length;\n\n    const uniqueFolders = new Set(cluster.paths.map(getTopFolder));\n    const folderDiversity = uniqueFolders.size / cluster.paths.length;\n\n    const linkedRatio = await computeLinkedRatio(backend, cluster.paths);\n    const suggestIndexNote = linkedRatio < 0.3 && cluster.paths.length >= 5;\n\n    themes.push({\n      id: clusterIndex++,\n      label,\n      notes: cluster.paths.map((path, idx) => ({\n        path,\n        title: cluster.titles[idx],\n      })),\n      size: cluster.paths.length,\n      folderDiversity,\n      avgRecency,\n      linkedRatio,\n      suggestIndexNote,\n    });\n  }\n\n  // Step 6: rank by size * folderDiversity * recency_ratio\n  themes.sort(\n    (a, b) =>\n      b.size * b.folderDiversity * (b.avgRecency / now) -\n      a.size * a.folderDiversity * (a.avgRecency / now),\n  );\n\n  return {\n    themes: themes.slice(0, maxThemes),\n    totalNotesAnalyzed,\n    timeWindow: { from, to: now },\n  };\n}\n"],"mappings":"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"}