{"version":3,"file":"neighborhood-CETR_P4y.mjs","names":[],"sources":["../src/graph/neighborhood.ts"],"sourcesContent":["/**\n * neighborhood.ts — graph_neighborhood endpoint handler\n *\n * Given a set of vault note paths (typically the notes inside a cluster),\n * returns the individual note nodes and the wikilink edges between them.\n *\n * Optionally enriches with semantic edges computed from cosine similarity\n * between chunk embeddings stored in the federation database.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { Pool } from \"pg\";\nimport { deserializeEmbedding } from \"../memory/embeddings.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphNeighborhoodParams {\n  /** Vault-relative paths of notes in the cluster */\n  vault_paths: string[];\n  /** Numeric PAI project ID */\n  project_id: number;\n  /** Whether to compute semantic similarity edges (default: false) */\n  include_semantic_edges?: boolean;\n  /** Cosine similarity threshold for semantic edges (default: 0.7) */\n  semantic_threshold?: number;\n}\n\nexport interface NoteNode {\n  vault_path: string;\n  title: string;\n  folder: string;\n  observation_types: Record<string, number>;\n  dominant_type: string;\n  updated_at: number;\n  word_count: number;\n}\n\nexport interface NoteEdge {\n  source: string;\n  target: string;\n  type: \"wikilink\" | \"semantic\";\n  weight: number;\n}\n\nexport interface GraphNeighborhoodResult {\n  nodes: NoteNode[];\n  edges: NoteEdge[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction folderFromPath(vaultPath: string): string {\n  const lastSlash = vaultPath.lastIndexOf(\"/\");\n  return lastSlash === -1 ? \"\" : vaultPath.slice(0, lastSlash);\n}\n\nfunction cosineSimilarity(a: number[], b: number[]): number {\n  if (a.length !== b.length || a.length === 0) return 0;\n  let dot = 0;\n  let normA = 0;\n  let normB = 0;\n  for (let i = 0; i < a.length; i++) {\n    dot += a[i] * b[i];\n    normA += a[i] * a[i];\n    normB += b[i] * b[i];\n  }\n  if (normA === 0 || normB === 0) return 0;\n  return dot / (Math.sqrt(normA) * Math.sqrt(normB));\n}\n\nfunction dominantType(counts: Record<string, number>): string {\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  return dominant;\n}\n\n// ---------------------------------------------------------------------------\n// Observation type enrichment (same pattern as clusters.ts)\n// ---------------------------------------------------------------------------\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, projectId];\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         AND project_id = $2\n       GROUP BY unnested_path, type`,\n      params\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// Main handler\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphNeighborhood(\n  pool: Pool | null,\n  backend: StorageBackend,\n  params: GraphNeighborhoodParams\n): Promise<GraphNeighborhoodResult> {\n  const vaultPaths = params.vault_paths ?? [];\n  if (vaultPaths.length === 0) {\n    return { nodes: [], edges: [] };\n  }\n\n  const includeSemanticEdges = params.include_semantic_edges ?? false;\n  const semanticThreshold = params.semantic_threshold ?? 0.7;\n\n  // -------------------------------------------------------------------------\n  // 1. Fetch node metadata from vault_files\n  // -------------------------------------------------------------------------\n\n  const fileRows = await backend.getVaultFilesByPaths(vaultPaths);\n\n  const fileIndex = new Map<string, { vaultPath: string; title: string | null; indexedAt: number }>();\n  for (const row of fileRows) {\n    fileIndex.set(row.vaultPath, row);\n  }\n\n  // -------------------------------------------------------------------------\n  // 2. Fetch observation types (Postgres if available)\n  // -------------------------------------------------------------------------\n\n  const observationsByPath =\n    pool !== null\n      ? await fetchObservationTypes(pool, vaultPaths, params.project_id)\n      : new Map<string, Record<string, number>>();\n\n  // -------------------------------------------------------------------------\n  // 3. Build NoteNode array\n  // -------------------------------------------------------------------------\n\n  const nodes: NoteNode[] = vaultPaths.map((vp) => {\n    const fileRow = fileIndex.get(vp);\n    const fileName = vp.split(\"/\").pop() ?? vp;\n    const rawTitle = fileRow?.title ?? fileName.replace(/\\.md$/i, \"\");\n\n    const obsCounts = observationsByPath.get(vp) ?? {};\n\n    return {\n      vault_path: vp,\n      title: rawTitle,\n      folder: folderFromPath(vp),\n      observation_types: obsCounts,\n      dominant_type: dominantType(obsCounts),\n      updated_at: fileRow?.indexedAt ?? 0,\n      word_count: 0,\n    };\n  });\n\n  // -------------------------------------------------------------------------\n  // 4. Fetch wikilink edges between the provided paths\n  // -------------------------------------------------------------------------\n\n  const pathSet = new Set(vaultPaths);\n  const linkRows = await backend.getVaultLinksFromPaths(vaultPaths);\n\n  const edges: NoteEdge[] = [];\n\n  for (const row of linkRows) {\n    if (!row.targetPath || !pathSet.has(row.targetPath)) continue;\n\n    edges.push({\n      source: row.sourcePath,\n      target: row.targetPath,\n      type: \"wikilink\",\n      weight: 1.0,\n    });\n  }\n\n  // -------------------------------------------------------------------------\n  // 5. Optional: semantic edges\n  // -------------------------------------------------------------------------\n\n  if (includeSemanticEdges && vaultPaths.length > 1) {\n    // Fetch mean embeddings for all paths\n    const embeddings = new Map<string, number[]>();\n    for (const vp of vaultPaths) {\n      const chunkRows = await backend.getChunksForPath(params.project_id, vp);\n      const embRows = chunkRows.filter(r => r.embedding !== null) as Array<{ text: string; embedding: Buffer }>;\n      if (embRows.length === 0) continue;\n\n      let vecLen = 0;\n      const vectors: Float32Array[] = [];\n\n      for (const row of embRows) {\n        const arr = deserializeEmbedding(row.embedding);\n        if (vecLen === 0) vecLen = arr.length;\n        if (arr.length === vecLen) vectors.push(arr);\n      }\n\n      if (vectors.length === 0 || vecLen === 0) continue;\n\n      const mean = new Array<number>(vecLen).fill(0);\n      for (const vec of vectors) {\n        for (let i = 0; i < vecLen; i++) {\n          mean[i] += vec[i];\n        }\n      }\n      for (let i = 0; i < vecLen; i++) {\n        mean[i] /= vectors.length;\n      }\n      embeddings.set(vp, mean);\n    }\n\n    const existingEdgeKeys = new Set<string>(\n      edges.map((e) => `${e.source}|||${e.target}`)\n    );\n\n    const pathsWithEmbeddings = Array.from(embeddings.keys());\n    for (let i = 0; i < pathsWithEmbeddings.length; i++) {\n      for (let j = i + 1; j < pathsWithEmbeddings.length; j++) {\n        const pathA = pathsWithEmbeddings[i];\n        const pathB = pathsWithEmbeddings[j];\n\n        const vecA = embeddings.get(pathA)!;\n        const vecB = embeddings.get(pathB)!;\n\n        const sim = cosineSimilarity(vecA, vecB);\n        if (sim < semanticThreshold) continue;\n\n        const keyAB = `${pathA}|||${pathB}`;\n        const keyBA = `${pathB}|||${pathA}`;\n        if (existingEdgeKeys.has(keyAB) || existingEdgeKeys.has(keyBA)) continue;\n\n        edges.push({\n          source: pathA,\n          target: pathB,\n          type: \"semantic\",\n          weight: sim,\n        });\n        existingEdgeKeys.add(keyAB);\n      }\n    }\n  }\n\n  return { nodes, edges };\n}\n"],"mappings":"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"}