@tekmidian/pai 0.9.13 → 0.9.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/{auto-route-DL4ViDKC.mjs → auto-route-D54Kv7yI.mjs} +2 -2
  2. package/dist/{auto-route-DL4ViDKC.mjs.map → auto-route-D54Kv7yI.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +1365 -378
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/{clusters-CRlPBpq8.mjs → clusters-BwmdKB-V.mjs} +1 -1
  6. package/dist/{clusters-CRlPBpq8.mjs.map → clusters-BwmdKB-V.mjs.map} +1 -1
  7. package/dist/{config-BuhHWyOK.mjs → config-DqBY3aT0.mjs} +1 -1
  8. package/dist/{config-BuhHWyOK.mjs.map → config-DqBY3aT0.mjs.map} +1 -1
  9. package/dist/daemon/index.mjs +12 -8
  10. package/dist/daemon/index.mjs.map +1 -1
  11. package/dist/daemon-B-vBWU1i.mjs +1426 -0
  12. package/dist/daemon-B-vBWU1i.mjs.map +1 -0
  13. package/dist/daemon-mcp/index.mjs +2 -2
  14. package/dist/{detector-AzVtGLtL.mjs → detector-Q_lVXtj-.mjs} +1 -1
  15. package/dist/{detector-AzVtGLtL.mjs.map → detector-Q_lVXtj-.mjs.map} +1 -1
  16. package/dist/{factory-BDBIfTDC.mjs → factory-BufouUQ1.mjs} +3 -3
  17. package/dist/{factory-BDBIfTDC.mjs.map → factory-BufouUQ1.mjs.map} +1 -1
  18. package/dist/hooks/context-compression-hook.mjs +39 -0
  19. package/dist/hooks/context-compression-hook.mjs.map +2 -2
  20. package/dist/hooks/stop-hook.mjs +36 -0
  21. package/dist/hooks/stop-hook.mjs.map +2 -2
  22. package/dist/index.mjs +2 -2
  23. package/dist/indexer-D7MvSQPY.mjs +1 -0
  24. package/dist/{indexer-backend-DXkDh-kc.mjs → indexer-backend-BrrCnasS.mjs} +1 -1
  25. package/dist/{indexer-backend-DXkDh-kc.mjs.map → indexer-backend-BrrCnasS.mjs.map} +1 -1
  26. package/dist/{ipc-client-CoyUHPod.mjs → ipc-client-C3pjwy2m.mjs} +1 -1
  27. package/dist/{ipc-client-CoyUHPod.mjs.map → ipc-client-C3pjwy2m.mjs.map} +1 -1
  28. package/dist/{kg-entity-D5v7RCDi.mjs → kg-entity-DVzTsy6G.mjs} +1 -1
  29. package/dist/{kg-entity-D5v7RCDi.mjs.map → kg-entity-DVzTsy6G.mjs.map} +1 -1
  30. package/dist/kg-extraction-r5KkTmWY.mjs +524 -0
  31. package/dist/kg-extraction-r5KkTmWY.mjs.map +1 -0
  32. package/dist/{latent-ideas-DvWBRHsy.mjs → latent-ideas-dWVuI_dg.mjs} +2 -2
  33. package/dist/{latent-ideas-DvWBRHsy.mjs.map → latent-ideas-dWVuI_dg.mjs.map} +1 -1
  34. package/dist/{migrate-jokLenje.mjs → migrate-fLD6rAdO.mjs} +2 -2
  35. package/dist/{migrate-jokLenje.mjs.map → migrate-fLD6rAdO.mjs.map} +1 -1
  36. package/dist/{neighborhood-u8ytjmWq.mjs → neighborhood-DixmpfPT.mjs} +1 -1
  37. package/dist/{neighborhood-u8ytjmWq.mjs.map → neighborhood-DixmpfPT.mjs.map} +1 -1
  38. package/dist/{note-context-CG2_e-0W.mjs → note-context-BY7rfGdl.mjs} +1 -1
  39. package/dist/{note-context-CG2_e-0W.mjs.map → note-context-BY7rfGdl.mjs.map} +1 -1
  40. package/dist/{postgres-BGERehmX.mjs → postgres-Cp25xZes.mjs} +1 -1
  41. package/dist/{postgres-BGERehmX.mjs.map → postgres-Cp25xZes.mjs.map} +1 -1
  42. package/dist/{query-feedback-CQSumXDy.mjs → query-feedback-C0hKUHVz.mjs} +1 -1
  43. package/dist/{query-feedback-CQSumXDy.mjs.map → query-feedback-C0hKUHVz.mjs.map} +1 -1
  44. package/dist/{sqlite-xUe94oUZ.mjs → sqlite-B3JHfJc2.mjs} +1 -1
  45. package/dist/{sqlite-xUe94oUZ.mjs.map → sqlite-B3JHfJc2.mjs.map} +1 -1
  46. package/dist/{state-BIlxNRUn.mjs → state-BHZAhLou.mjs} +1 -1
  47. package/dist/{state-BIlxNRUn.mjs.map → state-BHZAhLou.mjs.map} +1 -1
  48. package/dist/{themes-9jxFn3Rf.mjs → themes-DO8mUVwD.mjs} +1 -1
  49. package/dist/{themes-9jxFn3Rf.mjs.map → themes-DO8mUVwD.mjs.map} +1 -1
  50. package/dist/{tools-CF0lB5lW.mjs → tools-C1JNgya7.mjs} +14 -14
  51. package/dist/{tools-CF0lB5lW.mjs.map → tools-C1JNgya7.mjs.map} +1 -1
  52. package/dist/{trace-C2XrzssW.mjs → trace-TYCLMCkY.mjs} +1 -1
  53. package/dist/{trace-C2XrzssW.mjs.map → trace-TYCLMCkY.mjs.map} +1 -1
  54. package/dist/{utils-QSfKagcj.mjs → utils-BAxjW3j8.mjs} +2 -2
  55. package/dist/{utils-QSfKagcj.mjs.map → utils-BAxjW3j8.mjs.map} +1 -1
  56. package/dist/{vault-indexer-iHRjcy6e.mjs → vault-indexer-DsPC6wQf.mjs} +1 -1
  57. package/dist/{vault-indexer-iHRjcy6e.mjs.map → vault-indexer-DsPC6wQf.mjs.map} +1 -1
  58. package/dist/{daemon-w2fE3UMz.mjs → work-queue-worker-94INtUiz.mjs} +54 -1398
  59. package/dist/work-queue-worker-94INtUiz.mjs.map +1 -0
  60. package/dist/{zettelkasten-BdaMzTGQ.mjs → zettelkasten-B-94vghH.mjs} +3 -3
  61. package/dist/{zettelkasten-BdaMzTGQ.mjs.map → zettelkasten-B-94vghH.mjs.map} +1 -1
  62. package/package.json +1 -1
  63. package/src/hooks/ts/pre-compact/context-compression-hook.ts +48 -0
  64. package/src/hooks/ts/stop/stop-hook.ts +40 -0
  65. package/dist/daemon-w2fE3UMz.mjs.map +0 -1
  66. package/dist/kg-extraction-Uvr9fTlz.mjs +0 -246
  67. package/dist/kg-extraction-Uvr9fTlz.mjs.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kg-extraction-r5KkTmWY.mjs","names":[],"sources":["../src/cli/commands/registry/utils.ts","../src/cli/commands/registry/scan.ts","../src/daemon/templates/triple-extraction-prompt.ts","../src/memory/kg-extraction.ts"],"sourcesContent":["/** Shared database helpers for registry command operations. */\n\nimport type { Database } from \"better-sqlite3\";\nimport { now } from \"../../utils.js\";\n\n/**\n * Upsert a project row. Returns { id, isNew }.\n *\n * Matching priority:\n * 1. root_path — most reliable; handles slug collisions\n * 2. encoded_dir — Claude project dirs are canonical\n * 3. Insert with suffix-deduplication on slug collision\n */\nexport function upsertProject(\n db: Database,\n slug: string,\n rootPath: string,\n encodedDir: string\n): { id: number; isNew: boolean } {\n const ts = now();\n\n const byPath = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined;\n\n if (byPath) {\n const encodedOwner = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(encodedDir) as { id: number } | undefined;\n\n if (!encodedOwner || encodedOwner.id === byPath.id) {\n db.prepare(\n \"UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(encodedDir, ts, byPath.id);\n }\n return { id: byPath.id, isNew: false };\n }\n\n const byEncoded = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(encodedDir) as { id: number } | undefined;\n\n if (byEncoded) {\n const pathOwner = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined;\n\n if (!pathOwner || pathOwner.id === byEncoded.id) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?\"\n ).run(rootPath, ts, byEncoded.id);\n }\n return { id: byEncoded.id, isNew: false };\n }\n\n // Insert — deduplicate slug with numeric suffix if needed.\n let finalSlug = slug;\n let attempt = 0;\n while (true) {\n const conflict = db\n .prepare(\"SELECT id FROM projects WHERE slug = ?\")\n .get(finalSlug) as { id: number } | undefined;\n if (!conflict) break;\n attempt++;\n finalSlug = `${slug}-${attempt}`;\n }\n\n const result = db\n .prepare(\n `INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)\n VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`\n )\n .run(finalSlug, finalSlug, rootPath, encodedDir, ts, ts);\n\n if (result.changes === 0) {\n const fallback =\n (db.prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\").get(encodedDir) as { id: number } | undefined) ??\n (db.prepare(\"SELECT id FROM projects WHERE root_path = ?\").get(rootPath) as { id: number } | undefined);\n\n if (fallback) {\n return { id: fallback.id, isNew: false };\n }\n\n throw new Error(\n `upsertProject: INSERT OR IGNORE was suppressed but no matching row found ` +\n `for root_path=${rootPath} encoded_dir=${encodedDir}`\n );\n }\n\n return { id: result.lastInsertRowid as number, isNew: true };\n}\n\n/** Upsert a session note. Returns true if newly inserted. */\nexport function upsertSession(\n db: Database,\n projectId: number,\n number: number,\n date: string,\n slug: string,\n title: string,\n filename: string\n): boolean {\n const existing = db\n .prepare(\"SELECT id FROM sessions WHERE project_id = ? AND number = ?\")\n .get(projectId, number);\n\n if (existing) return false;\n\n const ts = now();\n db.prepare(\n `INSERT INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`\n ).run(projectId, number, date, slug, title, filename, ts);\n\n return true;\n}\n","/** Registry scan command: walk ~/.claude/projects/ and populate the registry. */\n\nimport { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { join, basename, resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { ok, warn, err, dim, bold } from \"../../utils.js\";\nimport { encodeDir } from \"../../utils.js\";\nimport { decodeEncodedDir, slugify, parseSessionFilename, buildEncodedDirMap } from \"../../../registry/migrate.js\";\nimport { ensurePaiMarker, discoverPaiMarkers } from \"../../../registry/pai-marker.js\";\nimport { upsertProject, upsertSession } from \"./utils.js\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Config helpers\n// ---------------------------------------------------------------------------\n\nconst CLAUDE_PROJECTS_DIR = join(homedir(), \".claude\", \"projects\");\nconst PAI_CONFIG_DIR = join(homedir(), \".pai\");\nconst PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, \"config.json\");\n\ninterface PaiConfig {\n scan_dirs: string[];\n}\n\nexport function loadScanConfig(): PaiConfig {\n if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };\n try {\n return JSON.parse(readFileSync(PAI_CONFIG_FILE, \"utf8\")) as PaiConfig;\n } catch {\n return { scan_dirs: [] };\n }\n}\n\nexport function saveScanConfig(config: PaiConfig): void {\n mkdirSync(PAI_CONFIG_DIR, { recursive: true });\n writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + \"\\n\", \"utf8\");\n}\n\nexport function resolveHome(p: string): string {\n if (p.startsWith(\"~/\")) return join(homedir(), p.slice(2));\n return resolve(p);\n}\n\n// ---------------------------------------------------------------------------\n// File discovery\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively find all .md files in a directory, including YYYY/MM subdirectories.\n * Returns filenames (basename only).\n */\nexport function findNoteFiles(dir: string): string[] {\n const results: string[] = [];\n if (!existsSync(dir)) return results;\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.isFile() && entry.name.endsWith(\".md\")) {\n results.push(entry.name);\n } else if (entry.isDirectory() && /^\\d{4}$/.test(entry.name)) {\n const yearDir = join(dir, entry.name);\n for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) {\n if (monthEntry.isDirectory() && /^\\d{2}$/.test(monthEntry.name)) {\n const monthDir = join(yearDir, monthEntry.name);\n for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) {\n if (noteEntry.isFile() && noteEntry.name.endsWith(\".md\")) {\n results.push(noteEntry.name);\n }\n }\n }\n }\n }\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Scan result type\n// ---------------------------------------------------------------------------\n\nexport interface ScanResult {\n projectsScanned: number;\n projectsNew: number;\n projectsUpdated: number;\n sessionsScanned: number;\n sessionsNew: number;\n skipped: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Core scan logic\n// ---------------------------------------------------------------------------\n\nexport function performScan(db: Database): ScanResult {\n const result: ScanResult = {\n projectsScanned: 0,\n projectsNew: 0,\n projectsUpdated: 0,\n sessionsScanned: 0,\n sessionsNew: 0,\n skipped: [],\n };\n\n if (!existsSync(CLAUDE_PROJECTS_DIR)) {\n throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);\n }\n\n const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {\n const full = join(CLAUDE_PROJECTS_DIR, name);\n return statSync(full).isDirectory();\n });\n\n const lookupMap = buildEncodedDirMap();\n\n for (const encodedDir of entries) {\n const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n\n if (!existsSync(rootPath)) {\n result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);\n result.projectsScanned++;\n continue;\n }\n\n const slug = slugify(basename(rootPath) || encodedDir);\n const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);\n\n result.projectsScanned++;\n if (isNew) result.projectsNew++;\n else result.projectsUpdated++;\n\n try {\n ensurePaiMarker(rootPath, slug);\n } catch {\n // Non-fatal\n }\n\n const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, \"Notes\");\n\n if (existsSync(claudeNotesDir)) {\n const rootNotesDir = join(rootPath, \"Notes\");\n if (claudeNotesDir !== rootNotesDir) {\n db.prepare(\n \"UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(claudeNotesDir, Date.now(), id);\n }\n }\n\n if (!existsSync(claudeNotesDir)) continue;\n\n const noteFiles = findNoteFiles(claudeNotesDir);\n\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n result.sessionsScanned++;\n const isNewSession = upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename);\n if (isNewSession) result.sessionsNew++;\n }\n }\n\n // Phase 2: Scan project-root Notes/ for all registered active projects\n {\n const activeProjects = db\n .prepare(\"SELECT id, slug, root_path FROM projects WHERE status = 'active'\")\n .all() as { id: number; slug: string; root_path: string }[];\n\n for (const project of activeProjects) {\n const notesDir = join(project.root_path, \"Notes\");\n if (!existsSync(notesDir)) continue;\n\n let files: string[];\n try {\n files = findNoteFiles(notesDir);\n } catch {\n continue;\n }\n\n for (const filename of files) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n result.sessionsScanned++;\n const isNewSession = upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename);\n if (isNewSession) result.sessionsNew++;\n }\n }\n }\n\n // Phase 3: Scan extra directories from config\n const config = loadScanConfig();\n if (config.scan_dirs.length) {\n for (const rawDir of config.scan_dirs) {\n const scanDir = resolveHome(rawDir);\n if (!existsSync(scanDir)) {\n result.skipped.push(`${rawDir} (configured scan_dir not found)`);\n continue;\n }\n\n const children = readdirSync(scanDir).filter((name) => {\n if (name.startsWith(\".\")) return false;\n const full = join(scanDir, name);\n try { return statSync(full).isDirectory(); } catch { return false; }\n });\n\n for (const child of children) {\n const childPath = join(scanDir, child);\n const childSlug = slugify(child);\n const childEncoded = encodeDir(childPath);\n\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(childPath) as { id: number } | undefined;\n\n if (existing) {\n result.projectsScanned++;\n result.projectsUpdated++;\n\n try { ensurePaiMarker(childPath, childSlug); } catch { /* non-fatal */ }\n\n const notesDir = join(childPath, \"Notes\");\n if (existsSync(notesDir)) {\n const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(\".md\"));\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n result.sessionsScanned++;\n if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) {\n result.sessionsNew++;\n }\n }\n }\n continue;\n }\n\n const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);\n result.projectsScanned++;\n if (isNew) result.projectsNew++;\n else result.projectsUpdated++;\n\n try { ensurePaiMarker(childPath, childSlug); } catch { /* non-fatal */ }\n\n const notesDir = join(childPath, \"Notes\");\n if (existsSync(notesDir)) {\n const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(\".md\"));\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n result.sessionsScanned++;\n if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) {\n result.sessionsNew++;\n }\n }\n }\n }\n }\n }\n\n // Phase 4: Discover PAI.md markers in scan_dirs\n if (config.scan_dirs.length) {\n const resolvedScanDirs = config.scan_dirs.map(resolveHome).filter(existsSync);\n const markers = discoverPaiMarkers(resolvedScanDirs);\n\n for (const marker of markers) {\n const registeredRow = db\n .prepare(\"SELECT id, root_path, slug FROM projects WHERE slug = ?\")\n .get(marker.slug) as { id: number; root_path: string; slug: string } | undefined;\n\n if (!registeredRow) continue;\n\n if (registeredRow.root_path !== marker.projectRoot) {\n const newEncoded = encodeDir(marker.projectRoot);\n const now4 = Date.now();\n\n const encodedOwner = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(newEncoded) as { id: number } | undefined;\n const pathOwner = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(marker.projectRoot) as { id: number } | undefined;\n\n const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;\n const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;\n\n if (encodedSafe && pathSafe) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(marker.projectRoot, newEncoded, now4, registeredRow.id);\n } else if (pathSafe) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?\"\n ).run(marker.projectRoot, now4, registeredRow.id);\n }\n }\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// cmdScan\n// ---------------------------------------------------------------------------\n\n/**\n * Run the registry scan CLI command.\n *\n * @param opts.quick When true, skips verbose output (same scan, less noise).\n * The underlying scan is always incremental via upsert —\n * this flag exists for hook/daemon-triggered invocations that\n * want minimal log output.\n */\nexport function cmdScan(db: Database, opts: { quick?: boolean } = {}): void {\n const config = loadScanConfig();\n if (!opts.quick) {\n console.log(dim(\"Scanning ~/.claude/projects/ ...\"));\n if (config.scan_dirs.length) {\n console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(\", \")}`));\n }\n console.log(dim(\"Scanning project-root Notes/ directories ...\"));\n }\n\n let result: ScanResult;\n try {\n result = performScan(db);\n } catch (e) {\n console.error(err(String(e)));\n process.exit(1);\n }\n\n if (!opts.quick) {\n console.log(\n ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`)\n );\n console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));\n console.log(dim(` Sessions: ${result.sessionsNew} new`));\n\n if (result.skipped.length) {\n console.log();\n console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));\n for (const s of result.skipped.slice(0, 10)) {\n console.log(dim(` ${s}`));\n }\n if (result.skipped.length > 10) {\n console.log(dim(` ... and ${result.skipped.length - 10} more`));\n }\n }\n } else {\n // Quick mode: single compact line (stderr so it doesn't pollute pipe output)\n process.stderr.write(\n `[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions ` +\n `(${result.projectsNew}+${result.sessionsNew} new).\\n`\n );\n }\n}\n","/**\n * triple-extraction-prompt.ts — Prompt template for KG triple extraction.\n *\n * Used by the session-summary-worker to extract structured facts from\n * a completed session summary and store them in the temporal knowledge graph.\n */\n\nexport function buildTripleExtractionPrompt(params: {\n sessionContent: string;\n projectSlug: string;\n gitLog: string;\n}): string {\n return `Extract structured entities and relations from this coding session.\n\nOutput a single JSON object with two arrays: \"entities\" and \"relations\".\n\nEntity types: project | person | concept | tool | file | version | decision | technology | organization\n\nRules:\n- Be SPECIFIC: entity names must be concrete (e.g., \"FSRS\", \"Glidr\", \"Matthias\")\n- Use snake_case relation verb phrases (e.g., \"uses_algorithm\", \"decided_to\", \"shipped_version\")\n- Skip opinions, speculation, and \"we should\" statements\n- Skip entities obvious from project metadata unless they have a meaningful relation\n- Maximum 15 relations per session — pick the most important\n- Each entity should have a brief description (1 sentence, what it is in this context)\n- Each relation must reference entity names that appear in the entities array\n\nExample output:\n{\n \"entities\": [\n {\"name\": \"Glidr\", \"type\": \"project\", \"description\": \"Flashcard app using FSRS spaced repetition\"},\n {\"name\": \"FSRS\", \"type\": \"concept\", \"description\": \"Free Spaced Repetition Scheduler algorithm\"},\n {\"name\": \"Matthias\", \"type\": \"person\", \"description\": \"Developer of Glidr and Quassl\"},\n {\"name\": \"Quassl\", \"type\": \"project\", \"description\": \"iOS app being rewritten in Flutter\"},\n {\"name\": \"Flutter\", \"type\": \"technology\", \"description\": \"Cross-platform mobile framework\"}\n ],\n \"relations\": [\n {\"source\": \"Glidr\", \"relation\": \"uses_algorithm\", \"target\": \"FSRS\"},\n {\"source\": \"Glidr\", \"relation\": \"shipped_version\", \"target\": \"1.0.5\"},\n {\"source\": \"Matthias\", \"relation\": \"decided_to_rewrite\", \"target\": \"Quassl\"},\n {\"source\": \"Quassl\", \"relation\": \"migrating_to\", \"target\": \"Flutter\"}\n ]\n}\n\nPROJECT: ${params.projectSlug}\n\nSESSION CONTENT:\n${params.sessionContent}\n\nGIT COMMITS:\n${params.gitLog}\n\nJSON object (entities + relations):`;\n}\n","/**\n * kg-extraction.ts — Shared KG triple extraction logic.\n *\n * Extracted from session-summary-worker.ts so both the worker and the\n * CLI backfill (`pai kg backfill`) can use the same code path.\n *\n * Provides:\n * - findClaudeBinary() — locate the claude CLI\n * - spawnClaude() — generic prompt -> response runner (strips ANTHROPIC_API_KEY)\n * - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres\n */\n\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport type { Pool } from \"pg\";\nimport type { Database } from \"better-sqlite3\";\n\nimport { buildTripleExtractionPrompt } from \"../daemon/templates/triple-extraction-prompt.js\";\nimport { kgAdd, kgQuery, kgInvalidate } from \"./kg.js\";\nimport { upsertKgEntity } from \"./kg-entity.js\";\n\n// ---------------------------------------------------------------------------\n// Claude CLI binary discovery\n// ---------------------------------------------------------------------------\n\n/**\n * Find the `claude` CLI binary. Checks common installation locations first\n * (launchd PATH is minimal so bare \"claude\" often won't resolve).\n */\nexport function findClaudeBinary(): string | null {\n const candidates = [\n join(homedir(), \".local\", \"bin\", \"claude\"),\n join(homedir(), \".claude\", \"local\", \"claude\"),\n \"/usr/local/bin/claude\",\n \"/opt/homebrew/bin/claude\",\n ];\n\n for (const candidate of candidates) {\n try {\n if (existsSync(candidate)) return candidate;\n } catch { /* skip */ }\n }\n return \"claude\";\n}\n\nconst CLAUDE_TIMEOUT_MS: Record<string, number> = {\n haiku: 60_000,\n sonnet: 120_000,\n opus: 300_000,\n};\n\n/**\n * Spawn the claude CLI with a prompt on stdin and return stdout.\n *\n * IMPORTANT: ANTHROPIC_API_KEY is stripped from the spawned environment so\n * the CLI uses the user's Max plan (free) instead of billing the API key.\n */\nexport async function spawnClaude(\n prompt: string,\n model: \"haiku\" | \"sonnet\" | \"opus\" = \"sonnet\"\n): Promise<string | null> {\n const claudeBin = findClaudeBinary();\n if (!claudeBin) {\n process.stderr.write(\"[kg-extraction] claude CLI not found.\\n\");\n return null;\n }\n\n const { spawn } = await import(\"node:child_process\");\n\n return new Promise((resolve) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const { ANTHROPIC_API_KEY: _drop, ...envWithoutApiKey } = process.env;\n const child = spawn(\n claudeBin,\n [\"--model\", model, \"-p\", \"--no-session-persistence\"],\n { env: envWithoutApiKey, stdio: [\"pipe\", \"pipe\", \"pipe\"] }\n );\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => { stdout += chunk.toString(); });\n child.stderr.on(\"data\", (chunk: Buffer) => { stderr += chunk.toString(); });\n\n child.on(\"error\", (err: Error) => {\n if (timer) { clearTimeout(timer); timer = null; }\n process.stderr.write(`[kg-extraction] ${model} spawn error: ${err.message}\\n`);\n resolve(null);\n });\n\n child.on(\"close\", (code: number | null) => {\n if (timer) { clearTimeout(timer); timer = null; }\n if (code !== 0) {\n process.stderr.write(\n `[kg-extraction] ${model} exited ${code}: ${stderr.slice(0, 300)}\\n`\n );\n resolve(null);\n } else {\n resolve(stdout.trim() || null);\n }\n });\n\n timer = setTimeout(() => {\n process.stderr.write(`[kg-extraction] ${model} timed out — killing process.\\n`);\n child.kill(\"SIGTERM\");\n resolve(null);\n }, CLAUDE_TIMEOUT_MS[model] ?? 120_000);\n\n child.stdin.write(prompt);\n child.stdin.end();\n });\n}\n\n// ---------------------------------------------------------------------------\n// Triple extraction\n// ---------------------------------------------------------------------------\n\nexport interface ExtractTriplesParams {\n summaryText: string;\n projectSlug: string;\n projectId: number | null;\n sessionId: string;\n gitLog?: string;\n model?: \"haiku\" | \"sonnet\" | \"opus\";\n /** Optional federation SQLite db — when provided, entities are upserted into kg_entities (QW1) */\n federationDb?: Database;\n /** Tenant ID for multi-tenant entity scoping (default: \"default\") */\n tenantId?: string;\n}\n\nexport interface ExtractTriplesResult {\n extracted: number;\n added: number;\n superseded: number;\n}\n\n/**\n * Extract structured KG triples from a session summary and store them in\n * Postgres. Idempotent: if a (subject, predicate) pair already has the same\n * object, no new row is added; if the object differs, the old triple is\n * invalidated (valid_to = NOW()) and a new one is inserted.\n *\n * Best-effort: per-triple errors are caught and logged but never thrown.\n * Returns a small stats object so callers can report progress.\n */\nexport async function extractAndStoreTriples(\n pool: Pool,\n params: ExtractTriplesParams\n): Promise<ExtractTriplesResult> {\n const stats: ExtractTriplesResult = { extracted: 0, added: 0, superseded: 0 };\n\n const prompt = buildTripleExtractionPrompt({\n sessionContent: params.summaryText,\n projectSlug: params.projectSlug,\n gitLog: params.gitLog ?? \"\",\n });\n\n const jsonOutput = await spawnClaude(prompt, params.model ?? \"sonnet\");\n if (!jsonOutput) return stats;\n\n // Strip markdown code fences if Claude wrapped the JSON\n const cleaned = jsonOutput\n .replace(/^```json\\s*/m, \"\")\n .replace(/^```\\s*/m, \"\")\n .replace(/\\s*```$/m, \"\")\n .trim();\n\n // Support both legacy array format and new structured format\n type LegacyTriple = { subject: string; predicate: string; object: string };\n type NewRelation = { source: string; relation: string; target: string };\n type NewEntity = { name: string; type: string; description: string };\n type NewFormat = { entities: NewEntity[]; relations: NewRelation[] };\n\n let triples: Array<LegacyTriple>;\n try {\n const parsed = JSON.parse(cleaned);\n\n if (Array.isArray(parsed)) {\n // Legacy format: [{subject, predicate, object}]\n triples = parsed;\n } else if (parsed && typeof parsed === \"object\" && Array.isArray(parsed.relations)) {\n // New structured format: {entities: [...], relations: [...]}\n const newFmt = parsed as NewFormat;\n\n // QW1: Upsert entities into federation SQLite kg_entities table when db is available\n if (params.federationDb && Array.isArray(newFmt.entities)) {\n const tenantId = params.tenantId ?? \"default\";\n for (const entity of newFmt.entities) {\n if (!entity.name) continue;\n try {\n upsertKgEntity(params.federationDb, {\n name: entity.name,\n type: entity.type ?? \"unknown\",\n description: entity.description,\n tenantId,\n });\n } catch (entityErr) {\n process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\\n`);\n }\n }\n }\n\n triples = newFmt.relations.map((r: NewRelation) => ({\n subject: r.source,\n predicate: r.relation,\n object: r.target,\n }));\n } else {\n process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\\n`);\n return stats;\n }\n } catch (e) {\n process.stderr.write(`[kg-extraction] JSON parse failed: ${e}\\n`);\n return stats;\n }\n\n if (!Array.isArray(triples)) return stats;\n stats.extracted = triples.length;\n\n for (const t of triples) {\n if (!t.subject || !t.predicate || !t.object) continue;\n\n try {\n const existing = await kgQuery(pool, {\n subject: t.subject,\n predicate: t.predicate,\n project_id: params.projectId ?? undefined,\n });\n\n // If an identical (subject, predicate, object) is already valid, skip — idempotent\n const alreadyValid = existing.find((e) => e.object === t.object && !e.valid_to);\n if (alreadyValid) continue;\n\n // Invalidate any superseded triple (same subject+predicate, different object)\n const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);\n if (supersedes) {\n await kgInvalidate(pool, supersedes.id);\n stats.superseded++;\n }\n\n await kgAdd(pool, {\n subject: t.subject,\n predicate: t.predicate,\n object: t.object,\n project_id: params.projectId ?? undefined,\n source_session: params.sessionId,\n confidence: \"EXTRACTED\",\n });\n stats.added++;\n } catch (tripleErr) {\n process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\\n`);\n }\n }\n\n return stats;\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import "./embeddings-DGRAPAYb.mjs";
2
2
  import { n as TITLE_STOP_WORDS } from "./stop-words-BaMEGVeY.mjs";
3
- import { t as zettelThemes } from "./themes-9jxFn3Rf.mjs";
3
+ import { t as zettelThemes } from "./themes-DO8mUVwD.mjs";
4
4
  import { mkdirSync, writeFileSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
6
 
@@ -188,4 +188,4 @@ function handleIdeaMaterialize(params, vaultPath) {
188
188
 
189
189
  //#endregion
190
190
  export { handleGraphLatentIdeas, handleIdeaMaterialize };
191
- //# sourceMappingURL=latent-ideas-DvWBRHsy.mjs.map
191
+ //# sourceMappingURL=latent-ideas-dWVuI_dg.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"latent-ideas-DvWBRHsy.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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"}
1
+ {"version":3,"file":"latent-ideas-dWVuI_dg.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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"}
@@ -1,4 +1,4 @@
1
- import { h as smartDecodeDir } from "./utils-QSfKagcj.mjs";
1
+ import { h as smartDecodeDir } from "./utils-BAxjW3j8.mjs";
2
2
  import { existsSync, readFileSync, readdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -245,4 +245,4 @@ function migrateFromJson(db, registryPath = join(homedir(), ".claude", "session-
245
245
 
246
246
  //#endregion
247
247
  export { slugify as a, parseSessionFilename as i, decodeEncodedDir as n, migrateFromJson as r, buildEncodedDirMap as t };
248
- //# sourceMappingURL=migrate-jokLenje.mjs.map
248
+ //# sourceMappingURL=migrate-fLD6rAdO.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"migrate-jokLenje.mjs","names":[],"sources":["../src/registry/migrate.ts"],"sourcesContent":["/**\n * Migration helper: imports the existing JSON session-registry into the\n * new SQLite registry.db.\n *\n * Source file: ~/.claude/session-registry.json\n * Target: openRegistry() → projects + sessions tables\n *\n * The JSON registry uses encoded directory names as keys (Claude Code's\n * encoding: leading `/` is replaced by `-`, then each remaining `/` is also\n * replaced by `-`). This module reverses that encoding to recover the real\n * filesystem path.\n *\n * Session note filenames are expected in one of two formats:\n * Modern: \"NNNN - YYYY-MM-DD - Description.md\" (space-dash-space)\n * Legacy: \"NNNN_YYYY-MM-DD_description.md\" (underscores)\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport { smartDecodeDir } from \"../cli/utils.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Shape of a single entry in session-registry.json */\ninterface RegistryEntry {\n /** Absolute path to the Notes/ directory for this project */\n notesDir?: string;\n /** Display name stored in the registry (optional) */\n displayName?: string;\n /** Any other keys the file might carry */\n [key: string]: unknown;\n}\n\n/** Top-level shape of session-registry.json */\ntype SessionRegistry = Record<string, RegistryEntry>;\n\n// ---------------------------------------------------------------------------\n// Encoding / decoding\n// ---------------------------------------------------------------------------\n\n/**\n * Build a lookup table from session-registry.json mapping encoded_dir →\n * original_path. This is the authoritative source for decoding because the\n * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal\n * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.\n *\n * Example:\n * `-Users-alice--ssh` encodes `/Users/alice/.ssh`\n * `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`\n * encodes `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`\n *\n * @param jsonPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n * @returns Map from encoded_dir → original_path, or empty map if the file is\n * missing / unparseable.\n */\nexport function buildEncodedDirMap(\n jsonPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): Map<string, string> {\n const map = new Map<string, string>();\n if (!existsSync(jsonPath)) return map;\n\n try {\n const raw = readFileSync(jsonPath, \"utf8\");\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Support both formats:\n // list-based: { \"projects\": [ { \"encoded_dir\", \"original_path\" }, ... ] }\n // object-keyed: { \"<encoded_dir>\": { ... } } (original Claude format)\n if (Array.isArray(parsed.projects)) {\n for (const entry of parsed.projects as Array<Record<string, unknown>>) {\n const key = entry.encoded_dir as string | undefined;\n const val = entry.original_path as string | undefined;\n if (key && val) map.set(key, val);\n }\n } else {\n // Object-keyed format — keys are encoded dirs\n for (const [key, value] of Object.entries(parsed)) {\n if (key === \"version\") continue;\n const val = (value as Record<string, unknown>)?.original_path as\n | string\n | undefined;\n if (val) map.set(key, val);\n }\n }\n } catch {\n // Unparseable — return empty map; callers fall back to heuristic decode\n }\n\n return map;\n}\n\n/**\n * Reverse Claude Code's directory encoding.\n *\n * Claude Code's actual encoding rules:\n * - `/` (path separator) → `-`\n * - ` ` (space) → `--` (escaped)\n * - `.` (dot) → `--` (escaped)\n * - `-` (literal hyphen) → `--` (escaped)\n *\n * Because space, dot, and hyphen all encode to `--`, the encoding is\n * **lossy** — you cannot unambiguously reverse it. This function therefore\n * provides a *best-effort* heuristic decode (treating `--` as a literal `-`\n * which gives wrong results for paths with spaces or dots).\n *\n * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping\n * from session-registry.json instead of calling this function directly.\n *\n * Examples (best-effort, may be wrong for paths with spaces/dots):\n * `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`\n * `-Users-alice--ssh` → `/Users/alice/-ssh` ← WRONG (actually .ssh)\n *\n * @param encoded The Claude-encoded directory name.\n * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.\n * If provided and the key is found, that value is returned\n * instead of the heuristic result.\n */\nexport function decodeEncodedDir(\n encoded: string,\n lookupMap?: Map<string, string>\n): string {\n // Authoritative lookup wins — but only if the path actually exists on disk.\n // session-registry.json may contain stale or incorrectly decoded paths.\n if (lookupMap?.has(encoded)) {\n const mapped = lookupMap.get(encoded)!;\n if (existsSync(mapped)) return mapped;\n }\n\n // Filesystem-walking decode (handles spaces, dots, hyphens correctly)\n const smart = smartDecodeDir(encoded);\n if (smart) return smart;\n\n // Fall back to lookup map even if path doesn't exist (for display purposes)\n if (lookupMap?.has(encoded)) {\n return lookupMap.get(encoded)!;\n }\n\n // Last resort: every `-` maps to `/` (wrong for paths with spaces/dots/hyphens)\n if (encoded.startsWith(\"-\")) {\n return encoded.replace(/-/g, \"/\");\n }\n\n // Not a Claude-encoded path — return as-is\n return encoded;\n}\n\n// ---------------------------------------------------------------------------\n// Slug generation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a URL-safe kebab-case slug from an arbitrary string.\n *\n * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.\n */\nexport function slugify(value: string): string {\n // Take last path segment if it looks like a path\n const segment = value.includes(\"/\")\n ? value.replace(/\\/$/, \"\").split(\"/\").pop() ?? value\n : value;\n\n return segment\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\") // non-alphanumeric runs → single dash\n .replace(/^-+|-+$/g, \"\"); // trim leading/trailing dashes\n}\n\n// ---------------------------------------------------------------------------\n// Session note parsing\n// ---------------------------------------------------------------------------\n\ninterface ParsedSession {\n number: number;\n date: string;\n slug: string;\n title: string;\n filename: string;\n}\n\n/** Match `0027 - 2026-01-04 - Some Description.md` */\nconst MODERN_RE = /^(\\d{4})\\s+-\\s+(\\d{4}-\\d{2}-\\d{2})\\s+-\\s+(.+)\\.md$/i;\n\n/** Match `0027_2026-01-04_some_description.md` */\nconst LEGACY_RE = /^(\\d{4})_(\\d{4}-\\d{2}-\\d{2})_(.+)\\.md$/i;\n\n/**\n * Attempt to parse a session note filename into its structured parts.\n *\n * Returns `null` if the filename does not match either known format.\n */\nexport function parseSessionFilename(\n filename: string\n): ParsedSession | null {\n let m = MODERN_RE.exec(filename);\n if (m) {\n const [, num, date, description] = m;\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n m = LEGACY_RE.exec(filename);\n if (m) {\n const [, num, date, rawDesc] = m;\n const description = rawDesc.replace(/_/g, \" \");\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Migration\n// ---------------------------------------------------------------------------\n\nexport interface MigrationResult {\n projectsInserted: number;\n projectsSkipped: number;\n sessionsInserted: number;\n errors: string[];\n}\n\n/**\n * Migrate the existing JSON session-registry into the SQLite registry.\n *\n * @param db Open better-sqlite3 Database (target).\n * @param registryPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n *\n * The migration is idempotent: projects and sessions that already exist\n * (matched by slug / project_id+number) are silently skipped.\n */\nexport function migrateFromJson(\n db: Database,\n registryPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): MigrationResult {\n const result: MigrationResult = {\n projectsInserted: 0,\n projectsSkipped: 0,\n sessionsInserted: 0,\n errors: [],\n };\n\n // ── Load source file ──────────────────────────────────────────────────────\n if (!existsSync(registryPath)) {\n result.errors.push(`Registry file not found: ${registryPath}`);\n return result;\n }\n\n let registry: SessionRegistry;\n try {\n const raw = readFileSync(registryPath, \"utf8\");\n registry = JSON.parse(raw) as SessionRegistry;\n } catch (err) {\n result.errors.push(`Failed to parse registry JSON: ${String(err)}`);\n return result;\n }\n\n // ── Prepared statements ───────────────────────────────────────────────────\n const insertProject = db.prepare(`\n INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status,\n created_at, updated_at)\n VALUES\n (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',\n @created_at, @updated_at)\n `);\n\n const getProject = db.prepare(\n \"SELECT id FROM projects WHERE slug = ?\"\n );\n\n const insertSession = db.prepare(`\n INSERT OR IGNORE INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES\n (@project_id, @number, @date, @slug, @title, @filename, 'completed',\n @created_at)\n `);\n\n const now = Date.now();\n\n // ── Build authoritative encoded-dir → path lookup ─────────────────────────\n const lookupMap = buildEncodedDirMap(registryPath);\n\n // ── Process each encoded directory entry ──────────────────────────────────\n for (const [encodedDir, entry] of Object.entries(registry)) {\n const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n const baseSlug = slugify(rootPath);\n\n // --- Upsert project ---\n let slug = baseSlug;\n let attempt = 0;\n while (true) {\n const info = insertProject.run({\n slug,\n display_name:\n (entry.displayName as string | undefined) ??\n (rootPath.split(\"/\").pop() ?? rootPath),\n root_path: rootPath,\n encoded_dir: encodedDir,\n created_at: now,\n updated_at: now,\n });\n\n if (info.changes > 0) {\n result.projectsInserted++;\n break;\n }\n\n // Row existed — check if it's ours (matching root_path) or a collision\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath);\n if (existing) {\n result.projectsSkipped++;\n break;\n }\n\n // Genuine slug collision — append numeric suffix and retry\n attempt++;\n slug = `${baseSlug}-${attempt}`;\n }\n\n const projectRow = getProject.get(slug) as { id: number } | undefined;\n // Also check by root_path in case slug was different\n const projectById = projectRow ??\n (db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined);\n\n if (!projectById) {\n result.errors.push(\n `Could not resolve project id for encoded dir: ${encodedDir}`\n );\n continue;\n }\n\n const projectId = projectById.id;\n\n // --- Scan Notes/ directory for session notes ---\n const notesDir =\n typeof entry.notesDir === \"string\"\n ? entry.notesDir\n : join(rootPath, \"Notes\");\n\n if (!existsSync(notesDir)) {\n // No notes directory — that is fine, project still gets created\n continue;\n }\n\n let files: string[];\n try {\n files = readdirSync(notesDir);\n } catch (err) {\n result.errors.push(\n `Cannot read notes dir ${notesDir}: ${String(err)}`\n );\n continue;\n }\n\n for (const filename of files) {\n if (!filename.endsWith(\".md\")) continue;\n\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n try {\n const info = insertSession.run({\n project_id: projectId,\n number: parsed.number,\n date: parsed.date,\n slug: parsed.slug,\n title: parsed.title,\n filename: parsed.filename,\n created_at: now,\n });\n if (info.changes > 0) result.sessionsInserted++;\n } catch (err) {\n result.errors.push(\n `Failed to insert session ${filename}: ${String(err)}`\n );\n }\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"migrate-fLD6rAdO.mjs","names":[],"sources":["../src/registry/migrate.ts"],"sourcesContent":["/**\n * Migration helper: imports the existing JSON session-registry into the\n * new SQLite registry.db.\n *\n * Source file: ~/.claude/session-registry.json\n * Target: openRegistry() → projects + sessions tables\n *\n * The JSON registry uses encoded directory names as keys (Claude Code's\n * encoding: leading `/` is replaced by `-`, then each remaining `/` is also\n * replaced by `-`). This module reverses that encoding to recover the real\n * filesystem path.\n *\n * Session note filenames are expected in one of two formats:\n * Modern: \"NNNN - YYYY-MM-DD - Description.md\" (space-dash-space)\n * Legacy: \"NNNN_YYYY-MM-DD_description.md\" (underscores)\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport { smartDecodeDir } from \"../cli/utils.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Shape of a single entry in session-registry.json */\ninterface RegistryEntry {\n /** Absolute path to the Notes/ directory for this project */\n notesDir?: string;\n /** Display name stored in the registry (optional) */\n displayName?: string;\n /** Any other keys the file might carry */\n [key: string]: unknown;\n}\n\n/** Top-level shape of session-registry.json */\ntype SessionRegistry = Record<string, RegistryEntry>;\n\n// ---------------------------------------------------------------------------\n// Encoding / decoding\n// ---------------------------------------------------------------------------\n\n/**\n * Build a lookup table from session-registry.json mapping encoded_dir →\n * original_path. This is the authoritative source for decoding because the\n * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal\n * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.\n *\n * Example:\n * `-Users-alice--ssh` encodes `/Users/alice/.ssh`\n * `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`\n * encodes `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`\n *\n * @param jsonPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n * @returns Map from encoded_dir → original_path, or empty map if the file is\n * missing / unparseable.\n */\nexport function buildEncodedDirMap(\n jsonPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): Map<string, string> {\n const map = new Map<string, string>();\n if (!existsSync(jsonPath)) return map;\n\n try {\n const raw = readFileSync(jsonPath, \"utf8\");\n const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n // Support both formats:\n // list-based: { \"projects\": [ { \"encoded_dir\", \"original_path\" }, ... ] }\n // object-keyed: { \"<encoded_dir>\": { ... } } (original Claude format)\n if (Array.isArray(parsed.projects)) {\n for (const entry of parsed.projects as Array<Record<string, unknown>>) {\n const key = entry.encoded_dir as string | undefined;\n const val = entry.original_path as string | undefined;\n if (key && val) map.set(key, val);\n }\n } else {\n // Object-keyed format — keys are encoded dirs\n for (const [key, value] of Object.entries(parsed)) {\n if (key === \"version\") continue;\n const val = (value as Record<string, unknown>)?.original_path as\n | string\n | undefined;\n if (val) map.set(key, val);\n }\n }\n } catch {\n // Unparseable — return empty map; callers fall back to heuristic decode\n }\n\n return map;\n}\n\n/**\n * Reverse Claude Code's directory encoding.\n *\n * Claude Code's actual encoding rules:\n * - `/` (path separator) → `-`\n * - ` ` (space) → `--` (escaped)\n * - `.` (dot) → `--` (escaped)\n * - `-` (literal hyphen) → `--` (escaped)\n *\n * Because space, dot, and hyphen all encode to `--`, the encoding is\n * **lossy** — you cannot unambiguously reverse it. This function therefore\n * provides a *best-effort* heuristic decode (treating `--` as a literal `-`\n * which gives wrong results for paths with spaces or dots).\n *\n * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping\n * from session-registry.json instead of calling this function directly.\n *\n * Examples (best-effort, may be wrong for paths with spaces/dots):\n * `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`\n * `-Users-alice--ssh` → `/Users/alice/-ssh` ← WRONG (actually .ssh)\n *\n * @param encoded The Claude-encoded directory name.\n * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.\n * If provided and the key is found, that value is returned\n * instead of the heuristic result.\n */\nexport function decodeEncodedDir(\n encoded: string,\n lookupMap?: Map<string, string>\n): string {\n // Authoritative lookup wins — but only if the path actually exists on disk.\n // session-registry.json may contain stale or incorrectly decoded paths.\n if (lookupMap?.has(encoded)) {\n const mapped = lookupMap.get(encoded)!;\n if (existsSync(mapped)) return mapped;\n }\n\n // Filesystem-walking decode (handles spaces, dots, hyphens correctly)\n const smart = smartDecodeDir(encoded);\n if (smart) return smart;\n\n // Fall back to lookup map even if path doesn't exist (for display purposes)\n if (lookupMap?.has(encoded)) {\n return lookupMap.get(encoded)!;\n }\n\n // Last resort: every `-` maps to `/` (wrong for paths with spaces/dots/hyphens)\n if (encoded.startsWith(\"-\")) {\n return encoded.replace(/-/g, \"/\");\n }\n\n // Not a Claude-encoded path — return as-is\n return encoded;\n}\n\n// ---------------------------------------------------------------------------\n// Slug generation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a URL-safe kebab-case slug from an arbitrary string.\n *\n * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.\n */\nexport function slugify(value: string): string {\n // Take last path segment if it looks like a path\n const segment = value.includes(\"/\")\n ? value.replace(/\\/$/, \"\").split(\"/\").pop() ?? value\n : value;\n\n return segment\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\") // non-alphanumeric runs → single dash\n .replace(/^-+|-+$/g, \"\"); // trim leading/trailing dashes\n}\n\n// ---------------------------------------------------------------------------\n// Session note parsing\n// ---------------------------------------------------------------------------\n\ninterface ParsedSession {\n number: number;\n date: string;\n slug: string;\n title: string;\n filename: string;\n}\n\n/** Match `0027 - 2026-01-04 - Some Description.md` */\nconst MODERN_RE = /^(\\d{4})\\s+-\\s+(\\d{4}-\\d{2}-\\d{2})\\s+-\\s+(.+)\\.md$/i;\n\n/** Match `0027_2026-01-04_some_description.md` */\nconst LEGACY_RE = /^(\\d{4})_(\\d{4}-\\d{2}-\\d{2})_(.+)\\.md$/i;\n\n/**\n * Attempt to parse a session note filename into its structured parts.\n *\n * Returns `null` if the filename does not match either known format.\n */\nexport function parseSessionFilename(\n filename: string\n): ParsedSession | null {\n let m = MODERN_RE.exec(filename);\n if (m) {\n const [, num, date, description] = m;\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n m = LEGACY_RE.exec(filename);\n if (m) {\n const [, num, date, rawDesc] = m;\n const description = rawDesc.replace(/_/g, \" \");\n return {\n number: parseInt(num, 10),\n date,\n slug: slugify(description),\n title: description.trim(),\n filename,\n };\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Migration\n// ---------------------------------------------------------------------------\n\nexport interface MigrationResult {\n projectsInserted: number;\n projectsSkipped: number;\n sessionsInserted: number;\n errors: string[];\n}\n\n/**\n * Migrate the existing JSON session-registry into the SQLite registry.\n *\n * @param db Open better-sqlite3 Database (target).\n * @param registryPath Path to session-registry.json.\n * Defaults to ~/.claude/session-registry.json.\n *\n * The migration is idempotent: projects and sessions that already exist\n * (matched by slug / project_id+number) are silently skipped.\n */\nexport function migrateFromJson(\n db: Database,\n registryPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): MigrationResult {\n const result: MigrationResult = {\n projectsInserted: 0,\n projectsSkipped: 0,\n sessionsInserted: 0,\n errors: [],\n };\n\n // ── Load source file ──────────────────────────────────────────────────────\n if (!existsSync(registryPath)) {\n result.errors.push(`Registry file not found: ${registryPath}`);\n return result;\n }\n\n let registry: SessionRegistry;\n try {\n const raw = readFileSync(registryPath, \"utf8\");\n registry = JSON.parse(raw) as SessionRegistry;\n } catch (err) {\n result.errors.push(`Failed to parse registry JSON: ${String(err)}`);\n return result;\n }\n\n // ── Prepared statements ───────────────────────────────────────────────────\n const insertProject = db.prepare(`\n INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status,\n created_at, updated_at)\n VALUES\n (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',\n @created_at, @updated_at)\n `);\n\n const getProject = db.prepare(\n \"SELECT id FROM projects WHERE slug = ?\"\n );\n\n const insertSession = db.prepare(`\n INSERT OR IGNORE INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES\n (@project_id, @number, @date, @slug, @title, @filename, 'completed',\n @created_at)\n `);\n\n const now = Date.now();\n\n // ── Build authoritative encoded-dir → path lookup ─────────────────────────\n const lookupMap = buildEncodedDirMap(registryPath);\n\n // ── Process each encoded directory entry ──────────────────────────────────\n for (const [encodedDir, entry] of Object.entries(registry)) {\n const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n const baseSlug = slugify(rootPath);\n\n // --- Upsert project ---\n let slug = baseSlug;\n let attempt = 0;\n while (true) {\n const info = insertProject.run({\n slug,\n display_name:\n (entry.displayName as string | undefined) ??\n (rootPath.split(\"/\").pop() ?? rootPath),\n root_path: rootPath,\n encoded_dir: encodedDir,\n created_at: now,\n updated_at: now,\n });\n\n if (info.changes > 0) {\n result.projectsInserted++;\n break;\n }\n\n // Row existed — check if it's ours (matching root_path) or a collision\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath);\n if (existing) {\n result.projectsSkipped++;\n break;\n }\n\n // Genuine slug collision — append numeric suffix and retry\n attempt++;\n slug = `${baseSlug}-${attempt}`;\n }\n\n const projectRow = getProject.get(slug) as { id: number } | undefined;\n // Also check by root_path in case slug was different\n const projectById = projectRow ??\n (db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined);\n\n if (!projectById) {\n result.errors.push(\n `Could not resolve project id for encoded dir: ${encodedDir}`\n );\n continue;\n }\n\n const projectId = projectById.id;\n\n // --- Scan Notes/ directory for session notes ---\n const notesDir =\n typeof entry.notesDir === \"string\"\n ? entry.notesDir\n : join(rootPath, \"Notes\");\n\n if (!existsSync(notesDir)) {\n // No notes directory — that is fine, project still gets created\n continue;\n }\n\n let files: string[];\n try {\n files = readdirSync(notesDir);\n } catch (err) {\n result.errors.push(\n `Cannot read notes dir ${notesDir}: ${String(err)}`\n );\n continue;\n }\n\n for (const filename of files) {\n if (!filename.endsWith(\".md\")) continue;\n\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n try {\n const info = insertSession.run({\n project_id: projectId,\n number: parsed.number,\n date: parsed.date,\n slug: parsed.slug,\n title: parsed.title,\n filename: parsed.filename,\n created_at: now,\n });\n if (info.changes > 0) result.sessionsInserted++;\n } catch (err) {\n result.errors.push(\n `Failed to insert session ${filename}: ${String(err)}`\n );\n }\n }\n }\n\n return result;\n}\n"],"mappings":"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"}
@@ -132,4 +132,4 @@ async function handleGraphNeighborhood(pool, backend, params) {
132
132
 
133
133
  //#endregion
134
134
  export { handleGraphNeighborhood };
135
- //# sourceMappingURL=neighborhood-u8ytjmWq.mjs.map
135
+ //# sourceMappingURL=neighborhood-DixmpfPT.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"neighborhood-u8ytjmWq.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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"}
1
+ {"version":3,"file":"neighborhood-DixmpfPT.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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"}
@@ -123,4 +123,4 @@ async function handleGraphNoteContext(pool, backend, params) {
123
123
 
124
124
  //#endregion
125
125
  export { handleGraphNoteContext };
126
- //# sourceMappingURL=note-context-CG2_e-0W.mjs.map
126
+ //# sourceMappingURL=note-context-BY7rfGdl.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"note-context-CG2_e-0W.mjs","names":[],"sources":["../src/graph/note-context.ts"],"sourcesContent":["/**\n * note-context.ts — graph_note_context endpoint handler\n *\n * Given a single vault note path, returns ALL notes linked to or from it\n * across the entire vault (1-hop neighbourhood), plus the edges.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphNoteContextParams {\n vault_path: string;\n project_id: number;\n max_neighbors?: number;\n include_backlinks?: boolean;\n include_outlinks?: boolean;\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 GraphNoteContextResult {\n focal: NoteNode;\n neighbors: NoteNode[];\n edges: NoteEdge[];\n cluster_membership: Record<string, number>;\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 dominantType(counts: Record<string, number>): string {\n let best = \"unknown\";\n let maxCount = 0;\n for (const [type, n] of Object.entries(counts)) {\n if (n > maxCount) {\n maxCount = n;\n best = type;\n }\n }\n return best;\n}\n\n// ---------------------------------------------------------------------------\n// Observation type enrichment\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 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 [filePaths, projectId]\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\nfunction buildNoteNode(\n vaultPath: string,\n fileIndex: Map<string, { title: string | null; indexedAt: number }>,\n obsByPath: Map<string, Record<string, number>>\n): NoteNode {\n const fileRow = fileIndex.get(vaultPath);\n const fileName = vaultPath.split(\"/\").pop() ?? vaultPath;\n const rawTitle = fileRow?.title ?? fileName.replace(/\\.md$/i, \"\");\n const obsCounts = obsByPath.get(vaultPath) ?? {};\n\n return {\n vault_path: vaultPath,\n title: rawTitle,\n folder: folderFromPath(vaultPath),\n observation_types: obsCounts,\n dominant_type: dominantType(obsCounts),\n updated_at: fileRow?.indexedAt ?? 0,\n word_count: 0,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Main handler\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphNoteContext(\n pool: Pool | null,\n backend: StorageBackend,\n params: GraphNoteContextParams\n): Promise<GraphNoteContextResult> {\n const focalPath = params.vault_path;\n if (!focalPath) {\n throw new Error(\"graph_note_context: vault_path is required\");\n }\n\n const maxNeighbors = params.max_neighbors ?? 50;\n const includeBacklinks = params.include_backlinks !== false;\n const includeOutlinks = params.include_outlinks !== false;\n\n // -------------------------------------------------------------------------\n // 1. Collect 1-hop neighbor paths via vault_links\n // -------------------------------------------------------------------------\n\n const neighborPaths = new Set<string>();\n const rawEdges: Array<{ source: string; target: string }> = [];\n\n if (includeOutlinks) {\n const outLinks = await backend.getLinksFromSource(focalPath);\n for (const link of outLinks) {\n if (!link.targetPath) continue;\n neighborPaths.add(link.targetPath);\n rawEdges.push({ source: focalPath, target: link.targetPath });\n }\n }\n\n if (includeBacklinks) {\n const inLinks = await backend.getLinksToTarget(focalPath);\n for (const link of inLinks) {\n neighborPaths.add(link.sourcePath);\n rawEdges.push({ source: link.sourcePath, target: focalPath });\n }\n }\n\n // Cap neighbors at max_neighbors, keeping the most-linked ones\n let neighborPathList = Array.from(neighborPaths);\n if (neighborPathList.length > maxNeighbors) {\n const linkCount = new Map<string, number>();\n for (const e of rawEdges) {\n const neighbor = e.source === focalPath ? e.target : e.source;\n linkCount.set(neighbor, (linkCount.get(neighbor) ?? 0) + 1);\n }\n neighborPathList = neighborPathList\n .sort((a, b) => (linkCount.get(b) ?? 0) - (linkCount.get(a) ?? 0))\n .slice(0, maxNeighbors);\n }\n\n const retainedSet = new Set(neighborPathList);\n const retainedEdges = rawEdges.filter((e) => {\n const neighbor = e.source === focalPath ? e.target : e.source;\n return retainedSet.has(neighbor);\n });\n\n // -------------------------------------------------------------------------\n // 2. Fetch vault_files metadata for focal + all neighbors\n // -------------------------------------------------------------------------\n\n const allPaths = [focalPath, ...neighborPathList];\n const fileRows = await backend.getVaultFilesByPaths(allPaths);\n const fileIndex = new Map<string, { title: string | null; indexedAt: number }>(\n fileRows.map(f => [f.vaultPath, { title: f.title, indexedAt: f.indexedAt }])\n );\n\n // -------------------------------------------------------------------------\n // 3. Observation type enrichment (Postgres if available)\n // -------------------------------------------------------------------------\n\n const obsByPath =\n pool !== null\n ? await fetchObservationTypes(pool, allPaths, params.project_id)\n : new Map<string, Record<string, number>>();\n\n // -------------------------------------------------------------------------\n // 4. Build focal NoteNode\n // -------------------------------------------------------------------------\n\n const focal = buildNoteNode(focalPath, fileIndex, obsByPath);\n\n // -------------------------------------------------------------------------\n // 5. Build neighbor NoteNode array\n // -------------------------------------------------------------------------\n\n const neighbors: NoteNode[] = neighborPathList.map((vp) =>\n buildNoteNode(vp, fileIndex, obsByPath)\n );\n\n // -------------------------------------------------------------------------\n // 6. Deduplicate edges\n // -------------------------------------------------------------------------\n\n const edgeKeys = new Set<string>();\n const edges: NoteEdge[] = [];\n for (const e of retainedEdges) {\n const key = `${e.source}|||${e.target}`;\n if (!edgeKeys.has(key)) {\n edgeKeys.add(key);\n edges.push({\n source: e.source,\n target: e.target,\n type: \"wikilink\",\n weight: 1.0,\n });\n }\n }\n\n return {\n focal,\n neighbors,\n edges,\n cluster_membership: {},\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"note-context-BY7rfGdl.mjs","names":[],"sources":["../src/graph/note-context.ts"],"sourcesContent":["/**\n * note-context.ts — graph_note_context endpoint handler\n *\n * Given a single vault note path, returns ALL notes linked to or from it\n * across the entire vault (1-hop neighbourhood), plus the edges.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphNoteContextParams {\n vault_path: string;\n project_id: number;\n max_neighbors?: number;\n include_backlinks?: boolean;\n include_outlinks?: boolean;\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 GraphNoteContextResult {\n focal: NoteNode;\n neighbors: NoteNode[];\n edges: NoteEdge[];\n cluster_membership: Record<string, number>;\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 dominantType(counts: Record<string, number>): string {\n let best = \"unknown\";\n let maxCount = 0;\n for (const [type, n] of Object.entries(counts)) {\n if (n > maxCount) {\n maxCount = n;\n best = type;\n }\n }\n return best;\n}\n\n// ---------------------------------------------------------------------------\n// Observation type enrichment\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 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 [filePaths, projectId]\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\nfunction buildNoteNode(\n vaultPath: string,\n fileIndex: Map<string, { title: string | null; indexedAt: number }>,\n obsByPath: Map<string, Record<string, number>>\n): NoteNode {\n const fileRow = fileIndex.get(vaultPath);\n const fileName = vaultPath.split(\"/\").pop() ?? vaultPath;\n const rawTitle = fileRow?.title ?? fileName.replace(/\\.md$/i, \"\");\n const obsCounts = obsByPath.get(vaultPath) ?? {};\n\n return {\n vault_path: vaultPath,\n title: rawTitle,\n folder: folderFromPath(vaultPath),\n observation_types: obsCounts,\n dominant_type: dominantType(obsCounts),\n updated_at: fileRow?.indexedAt ?? 0,\n word_count: 0,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Main handler\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphNoteContext(\n pool: Pool | null,\n backend: StorageBackend,\n params: GraphNoteContextParams\n): Promise<GraphNoteContextResult> {\n const focalPath = params.vault_path;\n if (!focalPath) {\n throw new Error(\"graph_note_context: vault_path is required\");\n }\n\n const maxNeighbors = params.max_neighbors ?? 50;\n const includeBacklinks = params.include_backlinks !== false;\n const includeOutlinks = params.include_outlinks !== false;\n\n // -------------------------------------------------------------------------\n // 1. Collect 1-hop neighbor paths via vault_links\n // -------------------------------------------------------------------------\n\n const neighborPaths = new Set<string>();\n const rawEdges: Array<{ source: string; target: string }> = [];\n\n if (includeOutlinks) {\n const outLinks = await backend.getLinksFromSource(focalPath);\n for (const link of outLinks) {\n if (!link.targetPath) continue;\n neighborPaths.add(link.targetPath);\n rawEdges.push({ source: focalPath, target: link.targetPath });\n }\n }\n\n if (includeBacklinks) {\n const inLinks = await backend.getLinksToTarget(focalPath);\n for (const link of inLinks) {\n neighborPaths.add(link.sourcePath);\n rawEdges.push({ source: link.sourcePath, target: focalPath });\n }\n }\n\n // Cap neighbors at max_neighbors, keeping the most-linked ones\n let neighborPathList = Array.from(neighborPaths);\n if (neighborPathList.length > maxNeighbors) {\n const linkCount = new Map<string, number>();\n for (const e of rawEdges) {\n const neighbor = e.source === focalPath ? e.target : e.source;\n linkCount.set(neighbor, (linkCount.get(neighbor) ?? 0) + 1);\n }\n neighborPathList = neighborPathList\n .sort((a, b) => (linkCount.get(b) ?? 0) - (linkCount.get(a) ?? 0))\n .slice(0, maxNeighbors);\n }\n\n const retainedSet = new Set(neighborPathList);\n const retainedEdges = rawEdges.filter((e) => {\n const neighbor = e.source === focalPath ? e.target : e.source;\n return retainedSet.has(neighbor);\n });\n\n // -------------------------------------------------------------------------\n // 2. Fetch vault_files metadata for focal + all neighbors\n // -------------------------------------------------------------------------\n\n const allPaths = [focalPath, ...neighborPathList];\n const fileRows = await backend.getVaultFilesByPaths(allPaths);\n const fileIndex = new Map<string, { title: string | null; indexedAt: number }>(\n fileRows.map(f => [f.vaultPath, { title: f.title, indexedAt: f.indexedAt }])\n );\n\n // -------------------------------------------------------------------------\n // 3. Observation type enrichment (Postgres if available)\n // -------------------------------------------------------------------------\n\n const obsByPath =\n pool !== null\n ? await fetchObservationTypes(pool, allPaths, params.project_id)\n : new Map<string, Record<string, number>>();\n\n // -------------------------------------------------------------------------\n // 4. Build focal NoteNode\n // -------------------------------------------------------------------------\n\n const focal = buildNoteNode(focalPath, fileIndex, obsByPath);\n\n // -------------------------------------------------------------------------\n // 5. Build neighbor NoteNode array\n // -------------------------------------------------------------------------\n\n const neighbors: NoteNode[] = neighborPathList.map((vp) =>\n buildNoteNode(vp, fileIndex, obsByPath)\n );\n\n // -------------------------------------------------------------------------\n // 6. Deduplicate edges\n // -------------------------------------------------------------------------\n\n const edgeKeys = new Set<string>();\n const edges: NoteEdge[] = [];\n for (const e of retainedEdges) {\n const key = `${e.source}|||${e.target}`;\n if (!edgeKeys.has(key)) {\n edgeKeys.add(key);\n edges.push({\n source: e.source,\n target: e.target,\n type: \"wikilink\",\n weight: 1.0,\n });\n }\n }\n\n return {\n focal,\n neighbors,\n edges,\n cluster_membership: {},\n };\n}\n"],"mappings":"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"}
@@ -878,4 +878,4 @@ var PostgresBackend = class PostgresBackend {
878
878
 
879
879
  //#endregion
880
880
  export { PostgresBackend };
881
- //# sourceMappingURL=postgres-BGERehmX.mjs.map
881
+ //# sourceMappingURL=postgres-Cp25xZes.mjs.map