{"version":3,"file":"latent-ideas-BfL4nCQ3.mjs","names":[],"sources":["../src/graph/latent-ideas.ts"],"sourcesContent":["/**\n * latent-ideas.ts — graph_latent_ideas and idea_materialize endpoint handlers\n *\n * \"Latent ideas\" are recurring themes in the vault that exist as embedding\n * clusters but have NO dedicated note written about them yet.  PAI surfaces\n * these by running the same agglomerative clustering used by graph_clusters /\n * zettelThemes and then filtering OUT any cluster whose label is well-matched\n * by an existing note title.\n *\n * The materialize endpoint writes a new Markdown note to the vault filesystem\n * and returns its content so the plugin can open it immediately.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { TITLE_STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { zettelThemes } from \"../zettelkasten/themes.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphLatentIdeasParams {\n  project_id: number;\n  /** Minimum notes in a cluster (default: 3) */\n  min_cluster_size?: number;\n  /** Cap on returned ideas (default: 15) */\n  max_ideas?: number;\n  /** How far back to look in days (default: 180) */\n  lookback_days?: number;\n  /** Cosine similarity clustering threshold (default: 0.65) */\n  similarity_threshold?: number;\n}\n\nexport interface LatentIdeaSourceNote {\n  vault_path: string;\n  title: string;\n  /** How strongly this note relates to the theme (0-1) */\n  relevance: number;\n}\n\nexport interface LatentIdea {\n  id: number;\n  /** Auto-generated cluster label from zettelThemes */\n  label: string;\n  /** Number of notes touching this theme */\n  size: number;\n  /** 0-1, how likely this is a real coherent idea */\n  confidence: number;\n  /** Notes that contribute to this theme */\n  source_notes: LatentIdeaSourceNote[];\n  /** Cleaned-up version of label for a potential note title */\n  suggested_title: string;\n  /** Most common folder among source notes */\n  suggested_folder: string;\n  /** Number of distinct session date-folders (e.g. \"2026/03\") touching this theme */\n  sessions_count: number;\n}\n\nexport interface GraphLatentIdeasResult {\n  ideas: LatentIdea[];\n  total_clusters_analyzed: number;\n  /** How many clusters already have a matching note (excluded from results) */\n  materialized_count: number;\n}\n\n// ---------------------------------------------------------------------------\n// Materialize params / result\n// ---------------------------------------------------------------------------\n\nexport interface IdeaMaterializeParams {\n  idea_label: string;\n  /** User-chosen title for the new note */\n  title: string;\n  /** Vault-relative folder path where the note should be created */\n  folder: string;\n  /** Vault-relative paths of the source notes to link from the new note */\n  source_paths: string[];\n  project_id: number;\n}\n\nexport interface IdeaMaterializeResult {\n  /** Vault-relative path of the created note */\n  vault_path: string;\n  /** Generated markdown content */\n  content: string;\n  /** Number of wikilinks inserted */\n  links_created: number;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: check if a cluster already has a matching note\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when any existing vault note title closely matches the cluster\n * label — meaning a dedicated note already exists for this topic.\n *\n * Matching strategy (simple, fast, no embeddings needed):\n *   1. Lowercase both sides and split into words.\n *   2. Remove stop words from the label words.\n *   3. If ≥ 60% of the significant label words appear in a note title → match.\n */\n// TITLE_STOP_WORDS imported from utils/stop-words.ts\n\nfunction labelMatchesTitle(label: string, title: string): boolean {\n  const labelWords = label\n    .toLowerCase()\n    .split(/[\\s\\-_/]+/)\n    .filter((w) => w.length > 2 && !TITLE_STOP_WORDS.has(w));\n\n  if (labelWords.length === 0) return false;\n\n  const titleLower = title.toLowerCase();\n  const matchCount = labelWords.filter((w) => titleLower.includes(w)).length;\n  return matchCount / labelWords.length >= 0.6;\n}\n\n/**\n * Check whether any note indexed in the vault has a title matching the label.\n * Fetches all vault file rows via StorageBackend for efficiency.\n */\nasync function clusterHasMatchingNote(\n  backend: StorageBackend,\n  label: string,\n  notePaths: string[]\n): Promise<boolean> {\n  // First check the notes already in the cluster themselves — if any cluster\n  // member's title matches the label it IS the index note → materialized.\n  const pathSet = new Set(notePaths);\n\n  // Fetch all vault files (bounded — vault rarely > 50k notes)\n  const rows = await backend.getAllVaultFiles();\n\n  for (const row of rows) {\n    if (!row.title) continue;\n    // Skip notes already counted inside the cluster — they don't count as\n    // \"dedicated notes\"; we only skip a cluster if a SEPARATE note exists.\n    if (pathSet.has(row.vaultPath)) continue;\n    if (labelMatchesTitle(label, row.title)) return true;\n  }\n  return false;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: generate a clean suggested title\n// ---------------------------------------------------------------------------\n\nfunction toSuggestedTitle(label: string): string {\n  // Remove leading/trailing whitespace, capitalize each word, remove stop words\n  // that are all-lowercase at the start of the title.\n  const words = label\n    .trim()\n    .split(/\\s+/)\n    .map((w, i) => {\n      const lower = w.toLowerCase();\n      // Drop leading stop words (but keep if they're the only word)\n      if (i === 0 && TITLE_STOP_WORDS.has(lower) && label.trim().split(/\\s+/).length > 1) {\n        return \"\";\n      }\n      return w.charAt(0).toUpperCase() + w.slice(1);\n    })\n    .filter(Boolean);\n\n  return words.join(\" \") || label;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: find most common folder\n// ---------------------------------------------------------------------------\n\nfunction mostCommonFolder(vaultPaths: string[]): string {\n  const counts = new Map<string, number>();\n  for (const p of vaultPaths) {\n    const parts = p.split(\"/\");\n    const folder = parts.length > 1 ? parts.slice(0, -1).join(\"/\") : \"\";\n    counts.set(folder, (counts.get(folder) ?? 0) + 1);\n  }\n\n  let best = \"\";\n  let bestCount = 0;\n  for (const [folder, count] of counts) {\n    if (count > bestCount) {\n      bestCount = count;\n      best = folder;\n    }\n  }\n  return best;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: count distinct session date-folders\n// ---------------------------------------------------------------------------\n\n/**\n * Heuristic: vault notes are often stored in date-based folders like\n * \"2026/03/15\" or \"Daily/2026-03\".  We extract the first numeric path\n * segment that looks like a year (2020-2030) and group by year+month.\n *\n * Falls back to counting distinct top-level folders.\n */\nfunction countDistinctSessions(vaultPaths: string[]): number {\n  const sessions = new Set<string>();\n  const yearMonthRe = /\\b(202\\d)\\D?(0[1-9]|1[0-2])\\b/;\n\n  for (const p of vaultPaths) {\n    const m = yearMonthRe.exec(p);\n    if (m) {\n      sessions.add(`${m[1]}-${m[2]}`);\n    } else {\n      // Fallback: use top-level folder as a proxy for \"session bucket\"\n      const topFolder = p.split(\"/\")[0];\n      sessions.add(topFolder);\n    }\n  }\n  return sessions.size;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: calculate confidence score\n// ---------------------------------------------------------------------------\n\n/**\n * Confidence combines:\n *  - Cluster size (normalized, capped at 20 for max contribution)\n *  - Folder diversity (0-1 already)\n *  - Sessions count (normalized, capped at 5)\n *\n * Formula: 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore\n */\nfunction calcConfidence(\n  size: number,\n  folderDiversity: number,\n  sessionsCount: number\n): number {\n  const sizeScore = Math.min(size / 20, 1.0);\n  const sessionScore = Math.min(sessionsCount / 5, 1.0);\n  const raw = 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore;\n  return Math.round(raw * 100) / 100;\n}\n\n// ---------------------------------------------------------------------------\n// Main handler: graph_latent_ideas\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphLatentIdeas(\n  backend: StorageBackend,\n  params: GraphLatentIdeasParams\n): Promise<GraphLatentIdeasResult> {\n  const minClusterSize = params.min_cluster_size ?? 3;\n  const maxIdeas = params.max_ideas ?? 15;\n  const lookbackDays = params.lookback_days ?? 180;\n  const similarityThreshold = params.similarity_threshold ?? 0.65;\n\n  const { project_id: vaultProjectId } = params;\n  if (!vaultProjectId) {\n    throw new Error(\n      \"graph_latent_ideas: project_id is required (pass the vault project's numeric ID)\"\n    );\n  }\n\n  // Run the same clustering algorithm used by graph_clusters\n  const themeResult = await zettelThemes(backend, {\n    vaultProjectId,\n    lookbackDays,\n    minClusterSize,\n    maxThemes: maxIdeas * 3, // Over-fetch — many will be filtered as materialized\n    similarityThreshold,\n  });\n\n  const ideas: LatentIdea[] = [];\n  let materializedCount = 0;\n\n  for (const theme of themeResult.themes) {\n    const notePaths = theme.notes.map((n) => n.path);\n\n    // Check if a dedicated note already exists for this theme\n    if (await clusterHasMatchingNote(backend, theme.label, notePaths)) {\n      materializedCount++;\n      continue;\n    }\n\n    // This is a latent idea — no dedicated note exists yet\n    const suggestedFolder = mostCommonFolder(notePaths);\n    const sessionsCount = countDistinctSessions(notePaths);\n    const confidence = calcConfidence(theme.size, theme.folderDiversity, sessionsCount);\n\n    // Build source notes with relevance scores\n    // Relevance is approximated by position in cluster (centroid-closest first)\n    // zettelThemes returns notes in no guaranteed order; assign uniform relevance\n    // decreasing from 1.0 to 0.5 across the list.\n    const sourceNotes: LatentIdeaSourceNote[] = theme.notes.map((n, idx) => ({\n      vault_path: n.path,\n      title: n.title ?? n.path.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? n.path,\n      relevance: Math.round((1.0 - (idx / Math.max(theme.notes.length - 1, 1)) * 0.5) * 100) / 100,\n    }));\n\n    ideas.push({\n      id: theme.id,\n      label: theme.label,\n      size: theme.size,\n      confidence,\n      source_notes: sourceNotes,\n      suggested_title: toSuggestedTitle(theme.label),\n      suggested_folder: suggestedFolder,\n      sessions_count: sessionsCount,\n    });\n\n    if (ideas.length >= maxIdeas) break;\n  }\n\n  // Sort by confidence descending\n  ideas.sort((a, b) => b.confidence - a.confidence);\n\n  return {\n    ideas,\n    total_clusters_analyzed: themeResult.themes.length + materializedCount,\n    materialized_count: materializedCount,\n  };\n}\n\n// ---------------------------------------------------------------------------\n// Materialize handler: idea_materialize\n// ---------------------------------------------------------------------------\n\nexport function handleIdeaMaterialize(\n  params: IdeaMaterializeParams,\n  vaultPath: string\n): IdeaMaterializeResult {\n  const { idea_label, title, folder, source_paths } = params;\n\n  // Sanitize filename: replace characters illegal in filenames\n  const safeTitle = title.replace(/[/\\\\:*?\"<>|]/g, \"-\");\n  const fileName = `${safeTitle}.md`;\n\n  // Vault-relative path (forward slashes, no leading slash)\n  const relFolder = folder.replace(/^\\/+|\\/+$/g, \"\");\n  const vault_path = relFolder ? `${relFolder}/${fileName}` : fileName;\n\n  // Absolute filesystem path\n  const absPath = join(vaultPath, vault_path);\n  const absDir = dirname(absPath);\n\n  // Build wikilinks from source_paths\n  const wikilinks = source_paths\n    .map((p) => {\n      // Derive a display name: filename without extension\n      const name = p.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? p;\n      // Relative wikilink — use just the filename (Obsidian resolves by title)\n      return `- [[${name}]]`;\n    })\n    .join(\"\\n\");\n\n  const links_created = source_paths.length;\n\n  const content = [\n    `# ${title}`,\n    \"\",\n    `*Materialized from latent idea: \"${idea_label}\"*`,\n    `*Sources: ${links_created} notes*`,\n    \"\",\n    \"## Related Notes\",\n    \"\",\n    wikilinks || \"*(no source notes)*\",\n    \"\",\n    \"## Notes\",\n    \"\",\n    \"<!-- Add your thoughts about this idea here -->\",\n    \"\",\n  ].join(\"\\n\");\n\n  // Write the file (create parent directories as needed)\n  mkdirSync(absDir, { recursive: true });\n  writeFileSync(absPath, content, \"utf-8\");\n\n  return {\n    vault_path,\n    content,\n    links_created,\n  };\n}\n"],"mappings":"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"}