@tekmidian/pai 0.66.1 → 0.66.3
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.
- package/dist/{aibroker-client-B8c42Lh8.mjs → aibroker-client-Ck3p3k_-.mjs} +2 -2
- package/dist/{aibroker-client-C5Fw7DNz.mjs → aibroker-client-WCZi_tn-.mjs} +3 -3
- package/dist/{aibroker-client-C5Fw7DNz.mjs.map → aibroker-client-WCZi_tn-.mjs.map} +1 -1
- package/dist/{auto-route-o0BOsXn7.mjs → auto-route-BnizyALK.mjs} +65 -4
- package/dist/auto-route-BnizyALK.mjs.map +1 -0
- package/dist/auto-route-D6fW1Q8z.mjs +3 -0
- package/dist/{chain-CZfKIEq9.mjs → chain-Fz4NLm4D.mjs} +3 -4
- package/dist/{chain-CZfKIEq9.mjs.map → chain-Fz4NLm4D.mjs.map} +1 -1
- package/dist/cli/index.mjs +18 -23
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +17 -22
- package/dist/{config-YinjgXEJ.mjs → config-CnsDMETf.mjs} +1 -1
- package/dist/{config-BbLFD7Uf.mjs → config-Ddc4DIPk.mjs} +5 -3
- package/dist/config-Ddc4DIPk.mjs.map +1 -0
- package/dist/{context-handover-cache-pkHzmL3c.mjs → context-handover-cache-DcPEW6vf.mjs} +1221 -13
- package/dist/context-handover-cache-DcPEW6vf.mjs.map +1 -0
- package/dist/daemon/index.mjs +17 -19
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-Bl8TpHdM.mjs +5048 -0
- package/dist/daemon-Bl8TpHdM.mjs.map +1 -0
- package/dist/daemon-ZhHU6D0i.mjs +18 -0
- package/dist/daemon-mcp/index.mjs +12 -9
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{embeddings-CcscYWwk.mjs → embeddings-D0WOQpYR.mjs} +1 -1
- package/dist/{embeddings-Bx3q0QOY.mjs → embeddings-DMVzpvRZ.mjs} +18 -9
- package/dist/embeddings-DMVzpvRZ.mjs.map +1 -0
- package/dist/{env-JNEIrQWg.mjs → env-DiolswKQ.mjs} +1 -1
- package/dist/{env-JNEIrQWg.mjs.map → env-DiolswKQ.mjs.map} +1 -1
- package/dist/factory-BUnZgnKW.mjs +2892 -0
- package/dist/factory-BUnZgnKW.mjs.map +1 -0
- package/dist/factory-CrVlo2hY.mjs +8 -0
- package/dist/{fallback-CIiq2Raw.mjs → fallback-awOmq_9A.mjs} +23 -11
- package/dist/fallback-awOmq_9A.mjs.map +1 -0
- package/dist/{chunker-BH4i-F2b.mjs → helpers-CZsi_49C.mjs} +221 -2
- package/dist/helpers-CZsi_49C.mjs.map +1 -0
- package/dist/hooks/block-sleep-poll.mjs +6 -5
- package/dist/hooks/block-sleep-poll.mjs.map +3 -3
- package/dist/hooks/context-compression-hook.mjs +1 -0
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +6 -5
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +6 -5
- package/dist/hooks/post-compact-inject.mjs.map +3 -3
- package/dist/hooks/route-agents-to-worker.mjs +6 -5
- package/dist/hooks/route-agents-to-worker.mjs.map +3 -3
- package/dist/hooks/security-validator.mjs +1 -0
- package/dist/hooks/security-validator.mjs.map +2 -2
- package/dist/hooks/whisper-rules.mjs +1 -0
- package/dist/hooks/whisper-rules.mjs.map +2 -2
- package/dist/hooks/worker-guard.mjs +6 -5
- package/dist/hooks/worker-guard.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +6 -5
- package/dist/hooks/worker-proxy.mjs.map +3 -3
- package/dist/hooks/worker-status-line.mjs +6 -5
- package/dist/hooks/worker-status-line.mjs.map +3 -3
- package/dist/hooks/worker-supervision.mjs +6 -5
- package/dist/hooks/worker-supervision.mjs.map +3 -3
- package/dist/index.mjs +6 -7
- package/dist/{ipc-client-D16Xw6Uo.mjs → ipc-client-DkxRay-o.mjs} +2 -2
- package/dist/{ipc-client-D16Xw6Uo.mjs.map → ipc-client-DkxRay-o.mjs.map} +1 -1
- package/dist/{main-resolver-DPPwHNtn.mjs → main-resolver-CVGPICy-.mjs} +7 -7
- package/dist/{main-resolver-DPPwHNtn.mjs.map → main-resolver-CVGPICy-.mjs.map} +1 -1
- package/dist/main-resolver-DDShdDBm.mjs +7 -0
- package/dist/{migrate-BD7D8EEh.mjs → migrate-Bq0esMOa.mjs} +2 -2
- package/dist/{migrate-BD7D8EEh.mjs.map → migrate-Bq0esMOa.mjs.map} +1 -1
- package/dist/{pai-home-Cm9rcJgX.mjs → pai-home-UncxWxlX.mjs} +1 -1
- package/dist/{pai-home-Cm9rcJgX.mjs.map → pai-home-UncxWxlX.mjs.map} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs → pai-marker-DXVpFsYz.mjs} +1 -1
- package/dist/{pai-marker-D1MMswkz.mjs.map → pai-marker-DXVpFsYz.mjs.map} +1 -1
- package/dist/{planner-Dm5Acyo1.mjs → planner-GdiYe2FO.mjs} +10 -7
- package/dist/{planner-Dm5Acyo1.mjs.map → planner-GdiYe2FO.mjs.map} +1 -1
- package/dist/postgres-DKyiCA5V.mjs +5 -0
- package/dist/{postgres-Ceqsa64C.mjs → postgres-H85VSYHI.mjs} +364 -9
- package/dist/postgres-H85VSYHI.mjs.map +1 -0
- package/dist/{program-JJ0TwSHI.mjs → program-D3MSfx86.mjs} +142 -100
- package/dist/program-D3MSfx86.mjs.map +1 -0
- package/dist/{query-feedback-BIaZTTFO.mjs → query-feedback-BPdapXKd.mjs} +2 -2
- package/dist/{query-feedback-BIaZTTFO.mjs.map → query-feedback-BPdapXKd.mjs.map} +1 -1
- package/dist/{reranker-DKv80KO5.mjs → reranker-3lnggwgq.mjs} +1 -1
- package/dist/{reranker-DKv80KO5.mjs.map → reranker-3lnggwgq.mjs.map} +1 -1
- package/dist/{reranker-CFiEzHQu.mjs → reranker-CZ2mP4cf.mjs} +1 -1
- package/dist/router-BK-hFeQ6.mjs +3 -0
- package/dist/{router-a5G7q8_3.mjs → router-BaTbc9VX.mjs} +2 -2
- package/dist/{router-a5G7q8_3.mjs.map → router-BaTbc9VX.mjs.map} +1 -1
- package/dist/{run-BCpbxqA5.mjs → run-D9YbWsnL.mjs} +599 -22
- package/dist/run-D9YbWsnL.mjs.map +1 -0
- package/dist/{run-env-DRcp7A8K.mjs → run-env-CQRnxNht.mjs} +3 -3
- package/dist/{run-env-DRcp7A8K.mjs.map → run-env-CQRnxNht.mjs.map} +1 -1
- package/dist/{runtime-paths-CHTg3ywb.mjs → runtime-paths-Bw3Jk8qJ.mjs} +4 -2
- package/dist/runtime-paths-Bw3Jk8qJ.mjs.map +1 -0
- package/dist/{search-BOSphCJ1.mjs → search-CNAGTiJP.mjs} +2 -2
- package/dist/{search-BOSphCJ1.mjs.map → search-CNAGTiJP.mjs.map} +1 -1
- package/dist/{sources-CijVso3n.mjs → sources-CM2g-CLT.mjs} +2 -2
- package/dist/{sources-CijVso3n.mjs.map → sources-CM2g-CLT.mjs.map} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs → stop-words-DtxaTWU_.mjs} +1 -1
- package/dist/{stop-words-BdQuaE9K.mjs.map → stop-words-DtxaTWU_.mjs.map} +1 -1
- package/dist/{utils-C9HsYDpO.mjs → utils-DddRwMsG.mjs} +2 -2
- package/dist/{utils-C9HsYDpO.mjs.map → utils-DddRwMsG.mjs.map} +1 -1
- package/dist/{zettelkasten-CtGHQWPU.mjs → zettelkasten-BRIaHzFm.mjs} +149 -7
- package/dist/zettelkasten-BRIaHzFm.mjs.map +1 -0
- package/dist/zettelkasten-G0-ZTybr.mjs +5 -0
- package/docs/commands/memory.md +2 -0
- package/docs/commands/worker.md +2 -0
- package/package.json +1 -1
- package/dist/async-C2Bm_Lal.mjs +0 -300
- package/dist/async-C2Bm_Lal.mjs.map +0 -1
- package/dist/auto-route-o0BOsXn7.mjs.map +0 -1
- package/dist/chunker-BH4i-F2b.mjs.map +0 -1
- package/dist/clusters-BCtD3fbe.mjs +0 -169
- package/dist/clusters-BCtD3fbe.mjs.map +0 -1
- package/dist/config-BbLFD7Uf.mjs.map +0 -1
- package/dist/context-handover-cache-pkHzmL3c.mjs.map +0 -1
- package/dist/daemon-DXlaVCkw.mjs +0 -1741
- package/dist/daemon-DXlaVCkw.mjs.map +0 -1
- package/dist/daemon-DyORmxZu.mjs +0 -19
- package/dist/detector-BEPFsINR.mjs +0 -3
- package/dist/detector-BRTtAWrM.mjs +0 -65
- package/dist/detector-BRTtAWrM.mjs.map +0 -1
- package/dist/embeddings-Bx3q0QOY.mjs.map +0 -1
- package/dist/factory-CNpPQQ-5.mjs +0 -249
- package/dist/factory-CNpPQQ-5.mjs.map +0 -1
- package/dist/factory-DY7x73mE.mjs +0 -3
- package/dist/fallback-CIiq2Raw.mjs.map +0 -1
- package/dist/federation-db-BTyoufBh.mjs +0 -139
- package/dist/federation-db-BTyoufBh.mjs.map +0 -1
- package/dist/federation-db-HfIFc7FG.mjs +0 -3
- package/dist/helpers-BRCJg0G3.mjs +0 -223
- package/dist/helpers-BRCJg0G3.mjs.map +0 -1
- package/dist/indexer-backend-DFF2FrYx.mjs +0 -5
- package/dist/indexer-backend-isSLg6yE.mjs +0 -1
- package/dist/kg-entity-DCOcsVFD.mjs +0 -29
- package/dist/kg-entity-DCOcsVFD.mjs.map +0 -1
- package/dist/latent-ideas-wwAXquGe.mjs +0 -191
- package/dist/latent-ideas-wwAXquGe.mjs.map +0 -1
- package/dist/link-boost-CnI7UVMJ.mjs +0 -34
- package/dist/link-boost-CnI7UVMJ.mjs.map +0 -1
- package/dist/main-resolver-BKz_OuEh.mjs +0 -7
- package/dist/merge-2gqRPFu2.mjs +0 -3
- package/dist/merge-DgU9OgZy.mjs +0 -6
- package/dist/merge-DgU9OgZy.mjs.map +0 -1
- package/dist/module-paths-DdRzbkUI.mjs +0 -44
- package/dist/module-paths-DdRzbkUI.mjs.map +0 -1
- package/dist/neighborhood-2FsmzoxG.mjs +0 -114
- package/dist/neighborhood-2FsmzoxG.mjs.map +0 -1
- package/dist/note-context-De63k8na.mjs +0 -106
- package/dist/note-context-De63k8na.mjs.map +0 -1
- package/dist/postgres-Ceqsa64C.mjs.map +0 -1
- package/dist/program-JJ0TwSHI.mjs.map +0 -1
- package/dist/query-feedback-B_iigYj-.mjs +0 -3
- package/dist/registry-db-C7voqML9.mjs +0 -213
- package/dist/registry-db-C7voqML9.mjs.map +0 -1
- package/dist/registry-db-JHPhA8vF.mjs +0 -3
- package/dist/registry-postgres-nOfyBSIW.mjs +0 -795
- package/dist/registry-postgres-nOfyBSIW.mjs.map +0 -1
- package/dist/registry-sqlite-DrCW1aRK.mjs +0 -593
- package/dist/registry-sqlite-DrCW1aRK.mjs.map +0 -1
- package/dist/router-CXUGsv85.mjs +0 -3
- package/dist/run-BCpbxqA5.mjs.map +0 -1
- package/dist/runtime-paths-CHTg3ywb.mjs.map +0 -1
- package/dist/search-Bf3Kub3F.mjs +0 -3
- package/dist/server-DgmAHyFK.mjs +0 -403
- package/dist/server-DgmAHyFK.mjs.map +0 -1
- package/dist/session-keepalive-DxjTHGcK.mjs +0 -582
- package/dist/session-keepalive-DxjTHGcK.mjs.map +0 -1
- package/dist/sqlite-CqsTy6xo.mjs +0 -936
- package/dist/sqlite-CqsTy6xo.mjs.map +0 -1
- package/dist/state-DW8zdweW.mjs +0 -3
- package/dist/state-HyjqTihC.mjs +0 -76
- package/dist/state-HyjqTihC.mjs.map +0 -1
- package/dist/themes-CTaOj3e1.mjs +0 -148
- package/dist/themes-CTaOj3e1.mjs.map +0 -1
- package/dist/tools-BcOKPBtg.mjs +0 -1489
- package/dist/tools-BcOKPBtg.mjs.map +0 -1
- package/dist/tools-C-_l0o8Z.mjs +0 -6
- package/dist/trace-CtV0RO6n.mjs +0 -137
- package/dist/trace-CtV0RO6n.mjs.map +0 -1
- package/dist/utils-BrX9FLeK.mjs +0 -3
- package/dist/vault-indexer-BG1sEVyM.mjs +0 -537
- package/dist/vault-indexer-BG1sEVyM.mjs.map +0 -1
- package/dist/wakeup-D8n33hYV.mjs +0 -335
- package/dist/wakeup-D8n33hYV.mjs.map +0 -1
- package/dist/work-queue-worker-4tLa5gpQ.mjs +0 -567
- package/dist/work-queue-worker-4tLa5gpQ.mjs.map +0 -1
- package/dist/work-queue-worker-rFZvTO3p.mjs +0 -11
- package/dist/zettelkasten-CtGHQWPU.mjs.map +0 -1
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auto-route-o0BOsXn7.mjs","names":[],"sources":["../src/session/auto-route.ts"],"sourcesContent":["/**\n * Auto-route: automatic project routing suggestion on session start.\n *\n * Given a working directory (and optional conversation context), determine\n * which registered project the session belongs to.\n *\n * Strategy (in priority order):\n * 1. Path match — exact or parent-directory match in the project registry\n * 2. Marker walk — walk up from cwd looking for Notes/PAI.md, resolve slug\n * 3. Topic match — BM25 keyword search against memory (requires context text)\n *\n * The function is stateless and works directly against the registry/storage\n * backends (no daemon required), making it fast and safe to call during\n * session startup.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { RegistryBackend, Project } from \"../storage/registry-interface.js\";\nimport { resolve, dirname } from \"node:path\";\nimport { existsSync } from \"node:fs\";\nimport { readPaiMarker } from \"../registry/pai-marker.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport type AutoRouteMethod = \"path\" | \"marker\" | \"topic\";\n\nexport interface AutoRouteResult {\n /** Project slug */\n slug: string;\n /** Human-readable project name */\n display_name: string;\n /** Absolute path to the project root */\n root_path: string;\n /** How the project was detected */\n method: AutoRouteMethod;\n /** Confidence [0,1]: 1.0 for path/marker matches, BM25 fraction for topic */\n confidence: number;\n}\n\n// ---------------------------------------------------------------------------\n// Core function\n// ---------------------------------------------------------------------------\n\n/**\n * Determine which project a session should be routed to.\n *\n * @param registryBackend Registry backend (projects, sessions, ...)\n * @param federation Memory storage backend (needed only for topic fallback)\n * @param cwd Working directory to detect from (defaults to process.cwd())\n * @param context Optional conversation text for topic-based fallback\n * @returns Best project match, or null if nothing matched\n */\nexport async function autoRoute(\n registryBackend: RegistryBackend,\n federation: StorageBackend,\n cwd?: string,\n context?: string\n): Promise<AutoRouteResult | null> {\n const target = resolve(cwd ?? process.cwd());\n\n // -------------------------------------------------------------------------\n // Strategy 1: Path match via registry\n // -------------------------------------------------------------------------\n\n const pathMatch = await findProjectByPath(registryBackend, target);\n\n if (pathMatch) {\n return {\n slug: pathMatch.slug,\n display_name: pathMatch.display_name,\n root_path: pathMatch.root_path,\n method: \"path\",\n confidence: 1.0,\n };\n }\n\n // -------------------------------------------------------------------------\n // Strategy 2: PAI.md marker file walk\n //\n // Walk up from cwd, checking <dir>/Notes/PAI.md at each level.\n // Once found, resolve the slug against the registry to get full project info.\n // -------------------------------------------------------------------------\n\n const markerResult = await findMarkerUpward(registryBackend, target);\n if (markerResult) {\n return markerResult;\n }\n\n // -------------------------------------------------------------------------\n // Strategy 3: Topic detection (requires context text)\n // -------------------------------------------------------------------------\n\n if (context && context.trim().length > 0) {\n // Lazy import to avoid bundler pulling in daemon/index.mjs at module load time\n const { detectTopicShift } = await import(\"../topics/detector.js\");\n const topicResult = await detectTopicShift(\n registryBackend,\n federation,\n {\n context,\n threshold: 0.5, // Lower threshold for initial routing (vs shift detection)\n }\n );\n\n if (topicResult.suggestedProject && topicResult.confidence > 0) {\n // Look up the full project info from the registry\n const projectRow = await registryBackend.getProjectBySlug(topicResult.suggestedProject);\n\n if (projectRow && projectRow.status !== \"archived\") {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"topic\",\n confidence: topicResult.confidence,\n };\n }\n }\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Path-match helper\n// ---------------------------------------------------------------------------\n\n/**\n * Exact or parent-directory match against every non-archived project,\n * longest root_path wins. Mirrors src/cli/commands/detect.ts's detectProject()\n * matching logic (that file also enriches with session stats, which autoRoute\n * does not need).\n */\nasync function findProjectByPath(\n registryBackend: RegistryBackend,\n target: string\n): Promise<Project | null> {\n const projects = await registryBackend.listProjectsByPathLengthDesc({ excludeArchived: true });\n\n for (const p of projects) {\n const root = resolve(p.root_path);\n if (target === root) return p;\n if (target.startsWith(root + \"/\")) return p;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Marker walk helper\n// ---------------------------------------------------------------------------\n\n/**\n * Walk up the directory tree from `startDir`, checking each level for a\n * `Notes/PAI.md` file. If found, read the slug and look up the project.\n *\n * Stops at the filesystem root or after 20 levels (safety guard).\n */\nasync function findMarkerUpward(\n registryBackend: RegistryBackend,\n startDir: string\n): Promise<AutoRouteResult | null> {\n let current = startDir;\n let depth = 0;\n\n while (depth < 20) {\n const markerPath = `${current}/Notes/PAI.md`;\n\n if (existsSync(markerPath)) {\n const marker = readPaiMarker(current);\n\n if (marker && marker.status !== \"archived\") {\n // Resolve slug to full project info in the registry\n const projectRow = await registryBackend.getProjectBySlug(marker.slug);\n\n if (projectRow && projectRow.status !== \"archived\") {\n return {\n slug: projectRow.slug,\n display_name: projectRow.display_name,\n root_path: projectRow.root_path,\n method: \"marker\",\n confidence: 1.0,\n };\n }\n }\n }\n\n const parent = dirname(current);\n if (parent === current) break; // Reached filesystem root\n current = parent;\n depth++;\n }\n\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// Format helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Format an AutoRouteResult as a human-readable string for CLI output.\n */\nexport function formatAutoRoute(result: AutoRouteResult): string {\n const lines: string[] = [\n `slug: ${result.slug}`,\n `display_name: ${result.display_name}`,\n `root_path: ${result.root_path}`,\n `method: ${result.method}`,\n `confidence: ${(result.confidence * 100).toFixed(0)}%`,\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * Format an AutoRouteResult as JSON for machine consumption.\n */\nexport function formatAutoRouteJson(result: AutoRouteResult): string {\n return JSON.stringify(result, null, 2);\n}\n"],"mappings":";;;;;;;;;;;;;;AAsDA,eAAsB,UACpB,iBACA,YACA,KACA,SACiC;CACjC,MAAM,SAAS,QAAQ,OAAO,QAAQ,KAAK,CAAC;CAM5C,MAAM,YAAY,MAAM,kBAAkB,iBAAiB,OAAO;AAElE,KAAI,UACF,QAAO;EACL,MAAM,UAAU;EAChB,cAAc,UAAU;EACxB,WAAW,UAAU;EACrB,QAAQ;EACR,YAAY;EACb;CAUH,MAAM,eAAe,MAAM,iBAAiB,iBAAiB,OAAO;AACpE,KAAI,aACF,QAAO;AAOT,KAAI,WAAW,QAAQ,MAAM,CAAC,SAAS,GAAG;EAExC,MAAM,EAAE,qBAAqB,MAAM,OAAO;EAC1C,MAAM,cAAc,MAAM,iBACxB,iBACA,YACA;GACE;GACA,WAAW;GACZ,CACF;AAED,MAAI,YAAY,oBAAoB,YAAY,aAAa,GAAG;GAE9D,MAAM,aAAa,MAAM,gBAAgB,iBAAiB,YAAY,iBAAiB;AAEvF,OAAI,cAAc,WAAW,WAAW,WACtC,QAAO;IACL,MAAM,WAAW;IACjB,cAAc,WAAW;IACzB,WAAW,WAAW;IACtB,QAAQ;IACR,YAAY,YAAY;IACzB;;;AAKP,QAAO;;;;;;;;AAaT,eAAe,kBACb,iBACA,QACyB;CACzB,MAAM,WAAW,MAAM,gBAAgB,6BAA6B,EAAE,iBAAiB,MAAM,CAAC;AAE9F,MAAK,MAAM,KAAK,UAAU;EACxB,MAAM,OAAO,QAAQ,EAAE,UAAU;AACjC,MAAI,WAAW,KAAM,QAAO;AAC5B,MAAI,OAAO,WAAW,OAAO,IAAI,CAAE,QAAO;;AAG5C,QAAO;;;;;;;;AAaT,eAAe,iBACb,iBACA,UACiC;CACjC,IAAI,UAAU;CACd,IAAI,QAAQ;AAEZ,QAAO,QAAQ,IAAI;AAGjB,MAAI,WAFe,GAAG,QAAQ,eAEJ,EAAE;GAC1B,MAAM,SAAS,cAAc,QAAQ;AAErC,OAAI,UAAU,OAAO,WAAW,YAAY;IAE1C,MAAM,aAAa,MAAM,gBAAgB,iBAAiB,OAAO,KAAK;AAEtE,QAAI,cAAc,WAAW,WAAW,WACtC,QAAO;KACL,MAAM,WAAW;KACjB,cAAc,WAAW;KACzB,WAAW,WAAW;KACtB,QAAQ;KACR,YAAY;KACb;;;EAKP,MAAM,SAAS,QAAQ,QAAQ;AAC/B,MAAI,WAAW,QAAS;AACxB,YAAU;AACV;;AAGF,QAAO;;;;;AAwBT,SAAgB,oBAAoB,QAAiC;AACnE,QAAO,KAAK,UAAU,QAAQ,MAAM,EAAE"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"chunker-BH4i-F2b.mjs","names":[],"sources":["../src/utils/hash.ts","../src/memory/chunker.ts"],"sourcesContent":["/**\n * Shared hashing utilities. Centralises all SHA-256 usage so every module\n * obtains digests through the same function rather than inlining createHash.\n */\n\nimport { createHash } from \"node:crypto\";\n\n/**\n * Compute a SHA-256 hex digest of the given string.\n * Aliased as sha256File for compatibility with existing call-sites that use\n * that name to hash file contents.\n */\nexport function sha256(content: string): string {\n return createHash(\"sha256\").update(content).digest(\"hex\");\n}\n\n/** Alias kept for backwards compatibility with memory/indexer call-sites. */\nexport const sha256File = sha256;\n","/**\n * Markdown text chunker for the PAI memory engine.\n *\n * Splits markdown files into overlapping text segments suitable for BM25\n * full-text indexing. Respects heading boundaries where possible, falling\n * back to paragraph and sentence splitting when sections are large.\n */\n\nimport { sha256 } from \"../utils/hash.js\";\n\n/** Bump whenever chunk text or boundaries change; forces one re-chunk of every file. */\nexport const CHUNKER_VERSION = 2;\n\n/** File-level change-detection hash: content plus chunker version. */\nexport function fileContentHash(content: string, version: number = CHUNKER_VERSION): string {\n return sha256(`chunker v${version}\\n${content}`);\n}\n\nexport interface Chunk {\n text: string;\n startLine: number; // 1-indexed\n endLine: number; // 1-indexed, inclusive\n hash: string; // SHA-256 of text\n /** Ancestor heading titles, outermost first, including the chunk's own section heading. */\n headingPath: string[];\n}\n\nexport interface ChunkOptions {\n /** Approximate maximum tokens per chunk. Default 400. */\n maxTokens?: number;\n /** Overlap in tokens from the previous chunk. Default 80. */\n overlap?: number;\n}\n\nconst DEFAULT_MAX_TOKENS = 400;\nconst DEFAULT_OVERLAP = 80;\n\n/**\n * Approximate token count using a words * 1.3 heuristic.\n * Matches the OpenClaw estimate approach.\n */\nexport function estimateTokens(text: string): number {\n const wordCount = text.split(/\\s+/).filter(Boolean).length;\n return Math.ceil(wordCount * 1.3);\n}\n\n// sha256 imported from utils/hash.ts\n\n// ---------------------------------------------------------------------------\n// Heading parser (the only one — chunker and memory_outline both use it)\n// ---------------------------------------------------------------------------\n\nexport interface Heading {\n level: number; // 1-6\n title: string;\n line: number; // 1-indexed\n}\n\n/**\n * Parse ATX headings (levels 1-6) from lines. Lines inside ``` or ~~~ fences\n * are never headings.\n */\nexport function parseHeadings(lines: string[]): Heading[] {\n const headings: Heading[] = [];\n let fence: { ch: string; len: number } | null = null;\n\n for (let i = 0; i < lines.length; i++) {\n const text = lines[i] ?? \"\";\n const f = /^ {0,3}(`{3,}|~{3,})/.exec(text);\n if (f) {\n const marker = f[1]!;\n if (!fence) fence = { ch: marker[0]!, len: marker.length };\n else if (marker[0] === fence.ch && marker.length >= fence.len && !text.slice(f[0].length).trim()) fence = null;\n continue;\n }\n if (fence) continue;\n const h = /^ {0,3}(#{1,6})\\s+(.*?)(?:\\s+#+)?\\s*$/.exec(text);\n if (h && h[2]) headings.push({ level: h[1]!.length, title: h[2], line: i + 1 });\n }\n return headings;\n}\n\n/** Heading path (outermost first) in effect at each heading, via a level stack. */\nfunction headingPaths(headings: Heading[]): Map<number, string[]> {\n const paths = new Map<number, string[]>();\n const stack: Heading[] = [];\n for (const h of headings) {\n while (stack.length > 0 && stack[stack.length - 1]!.level >= h.level) stack.pop();\n stack.push(h);\n paths.set(h.line, stack.map((x) => x.title));\n }\n return paths;\n}\n\nexport interface OutlineNode {\n level: number;\n title: string;\n startLine: number;\n endLine: number;\n tokens: number;\n children: OutlineNode[];\n}\n\n/**\n * Heading tree of a markdown file. A section ends on the line before the next\n * heading of the same or higher level (or at EOF) and includes its children.\n */\nexport function buildOutline(content: string): OutlineNode[] {\n const lines = content.split(\"\\n\");\n const last = lines[lines.length - 1] === \"\" ? lines.length - 1 : lines.length;\n const headings = parseHeadings(lines);\n const roots: OutlineNode[] = [];\n const stack: OutlineNode[] = [];\n\n headings.forEach((h, i) => {\n const next = headings.slice(i + 1).find((n) => n.level <= h.level);\n const endLine = Math.max(h.line, next ? next.line - 1 : last);\n const node: OutlineNode = {\n level: h.level,\n title: h.title,\n startLine: h.line,\n endLine,\n tokens: estimateTokens(lines.slice(h.line - 1, endLine).join(\"\\n\")),\n children: [],\n };\n while (stack.length > 0 && stack[stack.length - 1]!.level >= h.level) stack.pop();\n (stack.length > 0 ? stack[stack.length - 1]!.children : roots).push(node);\n stack.push(node);\n });\n return roots;\n}\n\n// ---------------------------------------------------------------------------\n// Internal section / paragraph / sentence splitters\n// ---------------------------------------------------------------------------\n\n/**\n * A contiguous block of lines associated with an approximate token count.\n */\ninterface LineBlock {\n lines: Array<{ text: string; lineNo: number }>;\n tokens: number;\n headingPath: string[];\n}\n\n/**\n * Split content into sections delimited by ## or ### headings.\n * Each section starts at its heading line (or at line 1 for a preamble).\n */\nfunction splitBySections(\n lines: Array<{ text: string; lineNo: number }>,\n): LineBlock[] {\n const sections: LineBlock[] = [];\n let current: Array<{ text: string; lineNo: number }> = [];\n let currentPath: string[] = [];\n\n const headings = parseHeadings(lines.map((l) => l.text));\n const paths = headingPaths(headings);\n const splitAt = new Set(headings.filter((h) => h.level <= 3).map((h) => h.line));\n\n const flush = () => {\n const text = current.map((l) => l.text).join(\"\\n\");\n sections.push({ lines: current, tokens: estimateTokens(text), headingPath: currentPath });\n current = [];\n };\n\n for (const line of lines) {\n if (splitAt.has(line.lineNo)) {\n if (current.length > 0) flush();\n currentPath = paths.get(line.lineNo) ?? [];\n }\n current.push(line);\n }\n\n if (current.length > 0) flush();\n\n return sections;\n}\n\n/**\n * Split a LineBlock by double-newline paragraph boundaries.\n */\nfunction splitByParagraphs(block: LineBlock): LineBlock[] {\n const paragraphs: LineBlock[] = [];\n let current: Array<{ text: string; lineNo: number }> = [];\n\n for (const line of block.lines) {\n if (line.text.trim() === \"\" && current.length > 0) {\n // Empty line — potential paragraph boundary\n const text = current.map((l) => l.text).join(\"\\n\");\n paragraphs.push({ lines: [...current], tokens: estimateTokens(text), headingPath: block.headingPath });\n current = [];\n } else {\n current.push(line);\n }\n }\n\n if (current.length > 0) {\n const text = current.map((l) => l.text).join(\"\\n\");\n paragraphs.push({ lines: current, tokens: estimateTokens(text), headingPath: block.headingPath });\n }\n\n return paragraphs.length > 0 ? paragraphs : [block];\n}\n\n/**\n * Split a LineBlock by sentence boundaries (. ! ?) when even paragraphs are\n * too large. Works character-by-character within joined lines.\n */\nfunction splitBySentences(block: LineBlock, maxTokens: number): LineBlock[] {\n const fullText = block.lines.map((l) => l.text).join(\" \");\n // Very rough sentence split — split on '. ', '! ', '? ' followed by uppercase\n const sentenceRe = /(?<=[.!?])\\s+(?=[A-Z\"'])/g;\n const sentences = fullText.split(sentenceRe);\n\n const result: LineBlock[] = [];\n let accText = \"\";\n // We can't recover exact line numbers inside a single oversized paragraph,\n // so we approximate using the block's start/end lines distributed evenly.\n const startLine = block.lines[0]?.lineNo ?? 1;\n const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;\n const totalLines = endLine - startLine + 1;\n const linesPerSentence = Math.max(1, Math.floor(totalLines / Math.max(1, sentences.length)));\n\n let sentenceIdx = 0;\n let approxLine = startLine;\n\n const flush = () => {\n if (!accText.trim()) return;\n const endApprox = Math.min(approxLine + linesPerSentence - 1, endLine);\n result.push({\n lines: [{ text: accText.trim(), lineNo: approxLine }],\n tokens: estimateTokens(accText),\n headingPath: block.headingPath,\n });\n approxLine = endApprox + 1;\n accText = \"\";\n };\n\n for (const sentence of sentences) {\n sentenceIdx++;\n const candidateText = accText ? accText + \" \" + sentence : sentence;\n if (estimateTokens(candidateText) > maxTokens && accText) {\n flush();\n accText = sentence;\n } else {\n accText = candidateText;\n }\n }\n void sentenceIdx; // used only for iteration count\n flush();\n\n return result.length > 0 ? result : [block];\n}\n\n// ---------------------------------------------------------------------------\n// Overlap helper\n// ---------------------------------------------------------------------------\n\n/**\n * Extract the last `overlapTokens` worth of text from a list of previously\n * emitted chunks to prepend to the next chunk.\n */\nfunction buildOverlapPrefix(\n lastChunk: { text: string; startLine: number; endLine: number } | undefined,\n overlapTokens: number,\n): Array<{ text: string; lineNo: number }> {\n if (overlapTokens <= 0 || !lastChunk) return [];\n\n const lines = lastChunk.text.split(\"\\n\");\n const kept: string[] = [];\n let acc = 0;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const lineTokens = estimateTokens(lines[i] ?? \"\");\n acc += lineTokens;\n kept.unshift(lines[i] ?? \"\");\n if (acc >= overlapTokens) break;\n }\n\n // Distribute overlap lines across the lastChunk's line range\n const startLine = lastChunk.endLine - kept.length + 1;\n return kept.map((text, idx) => ({ text, lineNo: Math.max(lastChunk.startLine, startLine + idx) }));\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Chunk a markdown file into overlapping segments for BM25 indexing.\n *\n * Strategy:\n * 1. Split by headings (##, ###) as natural boundaries.\n * 2. If a section exceeds maxTokens, split by paragraphs.\n * 3. If a paragraph still exceeds maxTokens, split by sentences.\n * 4. Apply overlap: each chunk includes the last `overlap` tokens from the\n * previous chunk.\n */\n/**\n * Strip `<private>...</private>` blocks from content before indexing.\n * Content within these tags is excluded from memory — never stored or searched.\n */\nexport function stripPrivateTags(content: string): string {\n return content.replace(/<private>[\\s\\S]*?<\\/private>/gi, \"\");\n}\n\n/** One breadcrumb line (\"[A > B]\\n\") for a non-empty heading path, else \"\". */\nfunction breadcrumb(path: string[]): string {\n return path.length > 0 ? `[${path.join(\" > \")}]\\n` : \"\";\n}\n\nexport function chunkMarkdown(content: string, opts?: ChunkOptions): Chunk[] {\n const maxTokens = opts?.maxTokens ?? DEFAULT_MAX_TOKENS;\n const overlapTokens = opts?.overlap ?? DEFAULT_OVERLAP;\n\n // Strip private content before indexing\n content = stripPrivateTags(content);\n\n if (!content.trim()) return [];\n\n const rawLines = content.split(\"\\n\");\n const lines: Array<{ text: string; lineNo: number }> = rawLines.map((text, idx) => ({\n text,\n lineNo: idx + 1, // 1-indexed\n }));\n\n // Step 1: section split\n const sections = splitBySections(lines);\n\n // Step 2 & 3: further split oversized sections. The breadcrumb line is\n // prepended to every chunk, so it counts against the token budget.\n const finalBlocks: LineBlock[] = [];\n for (const section of sections) {\n const budget = Math.max(1, maxTokens - estimateTokens(breadcrumb(section.headingPath)));\n if (section.tokens <= budget) {\n finalBlocks.push(section);\n continue;\n }\n // Too big — split by paragraphs\n const paras = splitByParagraphs(section);\n for (const para of paras) {\n if (para.tokens <= budget) {\n finalBlocks.push(para);\n continue;\n }\n // Still too big — split by sentences\n const sentences = splitBySentences(para, budget);\n finalBlocks.push(...sentences);\n }\n }\n\n // Step 4: build final chunks with overlap\n const chunks: Chunk[] = [];\n let prev: { text: string; startLine: number; endLine: number } | undefined;\n\n for (const block of finalBlocks) {\n if (block.lines.length === 0) continue;\n\n // Build overlap prefix from the previous chunk's raw (unprefixed) text\n const overlapLines = buildOverlapPrefix(prev, overlapTokens);\n\n // Combine overlap + block lines\n const allLines = [...overlapLines, ...block.lines];\n const raw = allLines.map((l) => l.text).join(\"\\n\").trim();\n\n if (!raw) continue;\n\n const startLine = block.lines[0]?.lineNo ?? 1;\n const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;\n const text = breadcrumb(block.headingPath) + raw;\n\n prev = { text: raw, startLine, endLine };\n chunks.push({\n text,\n startLine,\n endLine,\n hash: sha256(text),\n headingPath: block.headingPath,\n });\n }\n\n return chunks;\n}\n"],"mappings":"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"}
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import { t as STOP_WORDS } from "./stop-words-BdQuaE9K.mjs";
|
|
2
|
-
|
|
3
|
-
//#region src/graph/clusters.ts
|
|
4
|
-
/**
|
|
5
|
-
* Aggregate per-path observation type counts into cluster-level counts,
|
|
6
|
-
* then pick the dominant type.
|
|
7
|
-
*/
|
|
8
|
-
function aggregateObservationTypes(paths, byPath) {
|
|
9
|
-
const counts = {};
|
|
10
|
-
for (const path of paths) {
|
|
11
|
-
const pathCounts = byPath.get(path);
|
|
12
|
-
if (!pathCounts) continue;
|
|
13
|
-
for (const [type, n] of Object.entries(pathCounts)) counts[type] = (counts[type] ?? 0) + n;
|
|
14
|
-
}
|
|
15
|
-
let dominant = "unknown";
|
|
16
|
-
let maxCount = 0;
|
|
17
|
-
for (const [type, n] of Object.entries(counts)) if (n > maxCount) {
|
|
18
|
-
maxCount = n;
|
|
19
|
-
dominant = type;
|
|
20
|
-
}
|
|
21
|
-
return {
|
|
22
|
-
dominant,
|
|
23
|
-
counts
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
const SKIP_PREFIXES = [
|
|
27
|
-
"Attachments/",
|
|
28
|
-
"🗓️ Daily Notes/",
|
|
29
|
-
"Copilot/copilot-conversations/",
|
|
30
|
-
"Z - Zettelkasten/Tweets/"
|
|
31
|
-
];
|
|
32
|
-
/**
|
|
33
|
-
* Cluster vault notes by wikilink connectivity when embeddings aren't available.
|
|
34
|
-
* Uses BFS to find connected components in the link graph, then picks the
|
|
35
|
-
* largest components as clusters. Labels are derived from the most common
|
|
36
|
-
* title words in each component.
|
|
37
|
-
*/
|
|
38
|
-
async function clusterByLinks(backend, lookbackDays, minSize, maxClusters) {
|
|
39
|
-
const now = Date.now();
|
|
40
|
-
const from = now - lookbackDays * 864e5;
|
|
41
|
-
const recentNotes = (await backend.getRecentVaultFiles(from)).filter((f) => f.vaultPath.endsWith(".md"));
|
|
42
|
-
const noteMap = /* @__PURE__ */ new Map();
|
|
43
|
-
for (const n of recentNotes) noteMap.set(n.vaultPath, {
|
|
44
|
-
title: n.title,
|
|
45
|
-
indexed_at: n.indexedAt
|
|
46
|
-
});
|
|
47
|
-
const adj = /* @__PURE__ */ new Map();
|
|
48
|
-
for (const path of noteMap.keys()) if (!adj.has(path)) adj.set(path, /* @__PURE__ */ new Set());
|
|
49
|
-
const linkGraph = await backend.getVaultLinkGraph();
|
|
50
|
-
for (const { source_path, target_path } of linkGraph) if (noteMap.has(source_path) && noteMap.has(target_path)) {
|
|
51
|
-
adj.get(source_path).add(target_path);
|
|
52
|
-
adj.get(target_path).add(source_path);
|
|
53
|
-
}
|
|
54
|
-
const degrees = [...adj.entries()].map(([p, s]) => ({
|
|
55
|
-
path: p,
|
|
56
|
-
degree: s.size
|
|
57
|
-
}));
|
|
58
|
-
degrees.sort((a, b) => b.degree - a.degree);
|
|
59
|
-
const hubThreshold = Math.max(10, degrees[Math.floor(degrees.length * .05)]?.degree ?? 10);
|
|
60
|
-
const hubNodes = /* @__PURE__ */ new Set();
|
|
61
|
-
for (const { path, degree } of degrees) if (degree >= hubThreshold) hubNodes.add(path);
|
|
62
|
-
else break;
|
|
63
|
-
for (const hub of hubNodes) adj.delete(hub);
|
|
64
|
-
for (const [, neighbors] of adj) for (const hub of hubNodes) neighbors.delete(hub);
|
|
65
|
-
const visited = /* @__PURE__ */ new Set();
|
|
66
|
-
const components = [];
|
|
67
|
-
for (const path of noteMap.keys()) {
|
|
68
|
-
if (visited.has(path) || hubNodes.has(path)) continue;
|
|
69
|
-
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
|
|
70
|
-
visited.add(path);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
const component = [];
|
|
74
|
-
const queue = [path];
|
|
75
|
-
visited.add(path);
|
|
76
|
-
while (queue.length > 0) {
|
|
77
|
-
const current = queue.shift();
|
|
78
|
-
component.push(current);
|
|
79
|
-
const neighbors = adj.get(current);
|
|
80
|
-
if (!neighbors) continue;
|
|
81
|
-
for (const neighbor of neighbors) if (!visited.has(neighbor) && !SKIP_PREFIXES.some((p) => neighbor.startsWith(p))) {
|
|
82
|
-
visited.add(neighbor);
|
|
83
|
-
queue.push(neighbor);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (component.length >= minSize) components.push(component);
|
|
87
|
-
}
|
|
88
|
-
components.sort((a, b) => b.length - a.length);
|
|
89
|
-
const topComponents = components.slice(0, maxClusters);
|
|
90
|
-
function generateLinkLabel(paths) {
|
|
91
|
-
const wordCounts = /* @__PURE__ */ new Map();
|
|
92
|
-
for (const p of paths) {
|
|
93
|
-
const title = noteMap.get(p)?.title;
|
|
94
|
-
if (!title) continue;
|
|
95
|
-
const words = title.toLowerCase().replace(/[^a-z0-9äöüàéèêëçñß\s]/g, " ").split(/\s+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w));
|
|
96
|
-
for (const word of words) wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);
|
|
97
|
-
}
|
|
98
|
-
return [...wordCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([w]) => w).join(" / ") || "Linked Notes";
|
|
99
|
-
}
|
|
100
|
-
return {
|
|
101
|
-
themes: topComponents.map((component, idx) => {
|
|
102
|
-
const notes = component.map((p) => ({
|
|
103
|
-
path: p,
|
|
104
|
-
title: noteMap.get(p)?.title ?? null
|
|
105
|
-
}));
|
|
106
|
-
const avgRecency = component.reduce((sum, p) => sum + (noteMap.get(p)?.indexed_at ?? 0), 0) / component.length;
|
|
107
|
-
const uniqueFolders = new Set(component.map((p) => p.split("/")[0]));
|
|
108
|
-
return {
|
|
109
|
-
id: idx,
|
|
110
|
-
label: generateLinkLabel(component),
|
|
111
|
-
notes,
|
|
112
|
-
size: component.length,
|
|
113
|
-
folderDiversity: uniqueFolders.size / component.length,
|
|
114
|
-
avgRecency,
|
|
115
|
-
linkedRatio: 1,
|
|
116
|
-
suggestIndexNote: component.length >= 10
|
|
117
|
-
};
|
|
118
|
-
}),
|
|
119
|
-
totalNotesAnalyzed: recentNotes.length,
|
|
120
|
-
timeWindow: {
|
|
121
|
-
from,
|
|
122
|
-
to: now
|
|
123
|
-
}
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
async function handleGraphClusters(backend, params) {
|
|
127
|
-
const minSize = params.min_size ?? 3;
|
|
128
|
-
const maxClusters = params.max_clusters ?? 20;
|
|
129
|
-
const lookbackDays = params.lookback_days ?? 90;
|
|
130
|
-
if (!(params.project_id ?? 0)) throw new Error("graph_clusters: project_id is required (pass the vault project's numeric ID)");
|
|
131
|
-
const themeResult = await clusterByLinks(backend, lookbackDays, minSize, maxClusters);
|
|
132
|
-
const allPaths = themeResult.themes.flatMap((t) => t.notes.map((n) => n.path));
|
|
133
|
-
const observationsByPath = await backend.getObservationTypesForPaths(allPaths, params.project_id);
|
|
134
|
-
const fileRows = await backend.getVaultFilesByPaths(allPaths);
|
|
135
|
-
const indexedAtMap = new Map(fileRows.map((f) => [f.vaultPath, f.indexedAt]));
|
|
136
|
-
const clusters = themeResult.themes.map((theme) => {
|
|
137
|
-
const notePaths = theme.notes.map((n) => n.path);
|
|
138
|
-
const notesWithTimestamps = theme.notes.map((n) => ({
|
|
139
|
-
vault_path: n.path,
|
|
140
|
-
title: n.title ?? n.path.split("/").pop() ?? n.path,
|
|
141
|
-
indexed_at: indexedAtMap.get(n.path) ?? 0
|
|
142
|
-
}));
|
|
143
|
-
const avgRecency = theme.avgRecency;
|
|
144
|
-
const { dominant, counts } = aggregateObservationTypes(notePaths, observationsByPath);
|
|
145
|
-
return {
|
|
146
|
-
id: theme.id,
|
|
147
|
-
label: theme.label,
|
|
148
|
-
size: theme.size,
|
|
149
|
-
folder_diversity: theme.folderDiversity,
|
|
150
|
-
avg_recency: avgRecency,
|
|
151
|
-
linked_ratio: theme.linkedRatio,
|
|
152
|
-
dominant_observation_type: dominant,
|
|
153
|
-
observation_type_counts: counts,
|
|
154
|
-
suggest_index_note: theme.suggestIndexNote,
|
|
155
|
-
has_idea_note: false,
|
|
156
|
-
notes: notesWithTimestamps
|
|
157
|
-
};
|
|
158
|
-
});
|
|
159
|
-
clusters.sort((a, b) => b.size - a.size);
|
|
160
|
-
return {
|
|
161
|
-
clusters: clusters.slice(0, maxClusters),
|
|
162
|
-
total_notes_analyzed: themeResult.totalNotesAnalyzed,
|
|
163
|
-
time_window: themeResult.timeWindow
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
//#endregion
|
|
168
|
-
export { handleGraphClusters };
|
|
169
|
-
//# sourceMappingURL=clusters-BCtD3fbe.mjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"clusters-BCtD3fbe.mjs","names":[],"sources":["../src/graph/clusters.ts"],"sourcesContent":["/**\n * clusters.ts — graph_clusters endpoint handler\n *\n * Reuses the zettelThemes() agglomerative clustering algorithm and enriches\n * each cluster with observation-type statistics, avg_recency from member\n * timestamps, and helper flags for the Obsidian knowledge plugin.\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphClustersParams {\n project_id?: number;\n min_size?: number;\n max_clusters?: number;\n lookback_days?: number;\n similarity_threshold?: number;\n}\n\nexport interface ClusterNode {\n id: number;\n label: string;\n size: number;\n folder_diversity: number;\n avg_recency: number;\n linked_ratio: number;\n dominant_observation_type: string;\n observation_type_counts: Record<string, number>;\n suggest_index_note: boolean;\n has_idea_note: boolean;\n notes: Array<{ vault_path: string; title: string; indexed_at: number }>;\n}\n\nexport interface GraphClustersResult {\n clusters: ClusterNode[];\n total_notes_analyzed: number;\n time_window: { from: number; to: number };\n}\n\n/**\n * Aggregate per-path observation type counts into cluster-level counts,\n * then pick the dominant type.\n */\nfunction aggregateObservationTypes(\n paths: string[],\n byPath: Map<string, Record<string, number>>\n): { dominant: string; counts: Record<string, number> } {\n const counts: Record<string, number> = {};\n for (const path of paths) {\n const pathCounts = byPath.get(path);\n if (!pathCounts) continue;\n for (const [type, n] of Object.entries(pathCounts)) {\n counts[type] = (counts[type] ?? 0) + n;\n }\n }\n\n let dominant = \"unknown\";\n let maxCount = 0;\n for (const [type, n] of Object.entries(counts)) {\n if (n > maxCount) {\n maxCount = n;\n dominant = type;\n }\n }\n\n return { dominant, counts };\n}\n\n// ---------------------------------------------------------------------------\n// Link-based fallback clustering (wikilink connected components)\n// ---------------------------------------------------------------------------\n\nconst SKIP_PREFIXES = [\n \"Attachments/\", \"🗓️ Daily Notes/\", \"Copilot/copilot-conversations/\",\n \"Z - Zettelkasten/Tweets/\",\n];\n\n/**\n * Cluster vault notes by wikilink connectivity when embeddings aren't available.\n * Uses BFS to find connected components in the link graph, then picks the\n * largest components as clusters. Labels are derived from the most common\n * title words in each component.\n */\nasync function clusterByLinks(\n backend: StorageBackend,\n lookbackDays: number,\n minSize: number,\n maxClusters: number,\n): Promise<{ themes: Array<{ id: number; label: string; notes: Array<{ path: string; title: string | null }>; size: number; folderDiversity: number; avgRecency: number; linkedRatio: number; suggestIndexNote: boolean }>; totalNotesAnalyzed: number; timeWindow: { from: number; to: number } }> {\n const now = Date.now();\n const from = now - lookbackDays * 86400000;\n\n // Get recent notes\n const recentFiles = await backend.getRecentVaultFiles(from);\n const recentNotes = recentFiles.filter(f => f.vaultPath.endsWith(\".md\"));\n\n const noteMap = new Map<string, { title: string | null; indexed_at: number }>();\n for (const n of recentNotes) {\n noteMap.set(n.vaultPath, { title: n.title, indexed_at: n.indexedAt });\n }\n\n // Build adjacency list from vault_links (only for recent notes)\n const adj = new Map<string, Set<string>>();\n for (const path of noteMap.keys()) {\n if (!adj.has(path)) adj.set(path, new Set());\n }\n\n const linkGraph = await backend.getVaultLinkGraph();\n\n for (const { source_path, target_path } of linkGraph) {\n if (noteMap.has(source_path) && noteMap.has(target_path)) {\n adj.get(source_path)!.add(target_path);\n adj.get(target_path)!.add(source_path);\n }\n }\n\n // Remove hub nodes before BFS\n const degrees = [...adj.entries()].map(([p, s]) => ({ path: p, degree: s.size }));\n degrees.sort((a, b) => b.degree - a.degree);\n const hubThreshold = Math.max(10, degrees[Math.floor(degrees.length * 0.05)]?.degree ?? 10);\n const hubNodes = new Set<string>();\n for (const { path, degree } of degrees) {\n if (degree >= hubThreshold) hubNodes.add(path);\n else break;\n }\n\n for (const hub of hubNodes) {\n adj.delete(hub);\n }\n for (const [, neighbors] of adj) {\n for (const hub of hubNodes) {\n neighbors.delete(hub);\n }\n }\n\n // BFS connected components\n const visited = new Set<string>();\n const components: string[][] = [];\n\n for (const path of noteMap.keys()) {\n if (visited.has(path) || hubNodes.has(path)) continue;\n if (SKIP_PREFIXES.some(p => path.startsWith(p))) { visited.add(path); continue; }\n const component: string[] = [];\n const queue = [path];\n visited.add(path);\n\n while (queue.length > 0) {\n const current = queue.shift()!;\n component.push(current);\n const neighbors = adj.get(current);\n if (!neighbors) continue;\n for (const neighbor of neighbors) {\n if (!visited.has(neighbor) && !SKIP_PREFIXES.some(p => neighbor.startsWith(p))) {\n visited.add(neighbor);\n queue.push(neighbor);\n }\n }\n }\n\n if (component.length >= minSize) {\n components.push(component);\n }\n }\n\n components.sort((a, b) => b.length - a.length);\n const topComponents = components.slice(0, maxClusters);\n\n // STOP_WORDS imported from utils/stop-words.ts (module-level import)\n\n function generateLinkLabel(paths: string[]): string {\n const wordCounts = new Map<string, number>();\n for (const p of paths) {\n const title = noteMap.get(p)?.title;\n if (!title) continue;\n const words = title.toLowerCase().replace(/[^a-z0-9äöüàéèêëçñß\\s]/g, \" \").split(/\\s+/)\n .filter(w => w.length > 2 && !STOP_WORDS.has(w));\n for (const word of words) {\n wordCounts.set(word, (wordCounts.get(word) ?? 0) + 1);\n }\n }\n const sorted = [...wordCounts.entries()].sort((a, b) => b[1] - a[1]);\n return sorted.slice(0, 3).map(([w]) => w).join(\" / \") || \"Linked Notes\";\n }\n\n const themes = topComponents.map((component, idx) => {\n const notes = component.map(p => ({\n path: p,\n title: noteMap.get(p)?.title ?? null,\n }));\n const avgRecency = component.reduce((sum, p) => sum + (noteMap.get(p)?.indexed_at ?? 0), 0) / component.length;\n const uniqueFolders = new Set(component.map(p => p.split(\"/\")[0]));\n\n return {\n id: idx,\n label: generateLinkLabel(component),\n notes,\n size: component.length,\n folderDiversity: uniqueFolders.size / component.length,\n avgRecency,\n linkedRatio: 1.0,\n suggestIndexNote: component.length >= 10,\n };\n });\n\n return {\n themes,\n totalNotesAnalyzed: recentNotes.length,\n timeWindow: { from, to: now },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Main handler\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphClusters(\n backend: StorageBackend,\n params: GraphClustersParams\n): Promise<GraphClustersResult> {\n const minSize = params.min_size ?? 3;\n const maxClusters = params.max_clusters ?? 20;\n const lookbackDays = params.lookback_days ?? 90;\n\n const vaultProjectId = params.project_id ?? 0;\n\n if (!vaultProjectId) {\n throw new Error(\n \"graph_clusters: project_id is required (pass the vault project's numeric ID)\"\n );\n }\n\n const themeResult = await clusterByLinks(backend, lookbackDays, minSize, maxClusters);\n\n const allPaths = themeResult.themes.flatMap((t) => t.notes.map((n) => n.path));\n\n const observationsByPath = await backend.getObservationTypesForPaths(allPaths, params.project_id);\n\n // Fetch indexed_at timestamps for all notes in bulk\n const fileRows = await backend.getVaultFilesByPaths(allPaths);\n const indexedAtMap = new Map<string, number>(fileRows.map(f => [f.vaultPath, f.indexedAt]));\n\n const clusters: ClusterNode[] = themeResult.themes.map((theme) => {\n const notePaths = theme.notes.map((n) => n.path);\n\n const notesWithTimestamps = theme.notes.map((n) => ({\n vault_path: n.path,\n title: n.title ?? n.path.split(\"/\").pop() ?? n.path,\n indexed_at: indexedAtMap.get(n.path) ?? 0,\n }));\n\n const avgRecency = theme.avgRecency;\n\n const { dominant, counts } = aggregateObservationTypes(\n notePaths,\n observationsByPath\n );\n\n return {\n id: theme.id,\n label: theme.label,\n size: theme.size,\n folder_diversity: theme.folderDiversity,\n avg_recency: avgRecency,\n linked_ratio: theme.linkedRatio,\n dominant_observation_type: dominant,\n observation_type_counts: counts,\n suggest_index_note: theme.suggestIndexNote,\n has_idea_note: false,\n notes: notesWithTimestamps,\n };\n });\n\n clusters.sort((a, b) => b.size - a.size);\n\n return {\n clusters: clusters.slice(0, maxClusters),\n total_notes_analyzed: themeResult.totalNotesAnalyzed,\n time_window: themeResult.timeWindow,\n };\n}\n"],"mappings":";;;;;;;AA+CA,SAAS,0BACP,OACA,QACsD;CACtD,MAAM,SAAiC,EAAE;AACzC,MAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,aAAa,OAAO,IAAI,KAAK;AACnC,MAAI,CAAC,WAAY;AACjB,OAAK,MAAM,CAAC,MAAM,MAAM,OAAO,QAAQ,WAAW,CAChD,QAAO,SAAS,OAAO,SAAS,KAAK;;CAIzC,IAAI,WAAW;CACf,IAAI,WAAW;AACf,MAAK,MAAM,CAAC,MAAM,MAAM,OAAO,QAAQ,OAAO,CAC5C,KAAI,IAAI,UAAU;AAChB,aAAW;AACX,aAAW;;AAIf,QAAO;EAAE;EAAU;EAAQ;;AAO7B,MAAM,gBAAgB;CACpB;CAAgB;CAAoB;CACpC;CACD;;;;;;;AAQD,eAAe,eACb,SACA,cACA,SACA,aACkS;CAClS,MAAM,MAAM,KAAK,KAAK;CACtB,MAAM,OAAO,MAAM,eAAe;CAIlC,MAAM,eADc,MAAM,QAAQ,oBAAoB,KAAK,EAC3B,QAAO,MAAK,EAAE,UAAU,SAAS,MAAM,CAAC;CAExE,MAAM,0BAAU,IAAI,KAA2D;AAC/E,MAAK,MAAM,KAAK,YACd,SAAQ,IAAI,EAAE,WAAW;EAAE,OAAO,EAAE;EAAO,YAAY,EAAE;EAAW,CAAC;CAIvE,MAAM,sBAAM,IAAI,KAA0B;AAC1C,MAAK,MAAM,QAAQ,QAAQ,MAAM,CAC/B,KAAI,CAAC,IAAI,IAAI,KAAK,CAAE,KAAI,IAAI,sBAAM,IAAI,KAAK,CAAC;CAG9C,MAAM,YAAY,MAAM,QAAQ,mBAAmB;AAEnD,MAAK,MAAM,EAAE,aAAa,iBAAiB,UACzC,KAAI,QAAQ,IAAI,YAAY,IAAI,QAAQ,IAAI,YAAY,EAAE;AACxD,MAAI,IAAI,YAAY,CAAE,IAAI,YAAY;AACtC,MAAI,IAAI,YAAY,CAAE,IAAI,YAAY;;CAK1C,MAAM,UAAU,CAAC,GAAG,IAAI,SAAS,CAAC,CAAC,KAAK,CAAC,GAAG,QAAQ;EAAE,MAAM;EAAG,QAAQ,EAAE;EAAM,EAAE;AACjF,SAAQ,MAAM,GAAG,MAAM,EAAE,SAAS,EAAE,OAAO;CAC3C,MAAM,eAAe,KAAK,IAAI,IAAI,QAAQ,KAAK,MAAM,QAAQ,SAAS,IAAK,GAAG,UAAU,GAAG;CAC3F,MAAM,2BAAW,IAAI,KAAa;AAClC,MAAK,MAAM,EAAE,MAAM,YAAY,QAC7B,KAAI,UAAU,aAAc,UAAS,IAAI,KAAK;KACzC;AAGP,MAAK,MAAM,OAAO,SAChB,KAAI,OAAO,IAAI;AAEjB,MAAK,MAAM,GAAG,cAAc,IAC1B,MAAK,MAAM,OAAO,SAChB,WAAU,OAAO,IAAI;CAKzB,MAAM,0BAAU,IAAI,KAAa;CACjC,MAAM,aAAyB,EAAE;AAEjC,MAAK,MAAM,QAAQ,QAAQ,MAAM,EAAE;AACjC,MAAI,QAAQ,IAAI,KAAK,IAAI,SAAS,IAAI,KAAK,CAAE;AAC7C,MAAI,cAAc,MAAK,MAAK,KAAK,WAAW,EAAE,CAAC,EAAE;AAAE,WAAQ,IAAI,KAAK;AAAE;;EACtE,MAAM,YAAsB,EAAE;EAC9B,MAAM,QAAQ,CAAC,KAAK;AACpB,UAAQ,IAAI,KAAK;AAEjB,SAAO,MAAM,SAAS,GAAG;GACvB,MAAM,UAAU,MAAM,OAAO;AAC7B,aAAU,KAAK,QAAQ;GACvB,MAAM,YAAY,IAAI,IAAI,QAAQ;AAClC,OAAI,CAAC,UAAW;AAChB,QAAK,MAAM,YAAY,UACrB,KAAI,CAAC,QAAQ,IAAI,SAAS,IAAI,CAAC,cAAc,MAAK,MAAK,SAAS,WAAW,EAAE,CAAC,EAAE;AAC9E,YAAQ,IAAI,SAAS;AACrB,UAAM,KAAK,SAAS;;;AAK1B,MAAI,UAAU,UAAU,QACtB,YAAW,KAAK,UAAU;;AAI9B,YAAW,MAAM,GAAG,MAAM,EAAE,SAAS,EAAE,OAAO;CAC9C,MAAM,gBAAgB,WAAW,MAAM,GAAG,YAAY;CAItD,SAAS,kBAAkB,OAAyB;EAClD,MAAM,6BAAa,IAAI,KAAqB;AAC5C,OAAK,MAAM,KAAK,OAAO;GACrB,MAAM,QAAQ,QAAQ,IAAI,EAAE,EAAE;AAC9B,OAAI,CAAC,MAAO;GACZ,MAAM,QAAQ,MAAM,aAAa,CAAC,QAAQ,2BAA2B,IAAI,CAAC,MAAM,MAAM,CACnF,QAAO,MAAK,EAAE,SAAS,KAAK,CAAC,WAAW,IAAI,EAAE,CAAC;AAClD,QAAK,MAAM,QAAQ,MACjB,YAAW,IAAI,OAAO,WAAW,IAAI,KAAK,IAAI,KAAK,EAAE;;AAIzD,SADe,CAAC,GAAG,WAAW,SAAS,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG,CACtD,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,KAAK,MAAM,IAAI;;AAuB3D,QAAO;EACL,QArBa,cAAc,KAAK,WAAW,QAAQ;GACnD,MAAM,QAAQ,UAAU,KAAI,OAAM;IAChC,MAAM;IACN,OAAO,QAAQ,IAAI,EAAE,EAAE,SAAS;IACjC,EAAE;GACH,MAAM,aAAa,UAAU,QAAQ,KAAK,MAAM,OAAO,QAAQ,IAAI,EAAE,EAAE,cAAc,IAAI,EAAE,GAAG,UAAU;GACxG,MAAM,gBAAgB,IAAI,IAAI,UAAU,KAAI,MAAK,EAAE,MAAM,IAAI,CAAC,GAAG,CAAC;AAElE,UAAO;IACL,IAAI;IACJ,OAAO,kBAAkB,UAAU;IACnC;IACA,MAAM,UAAU;IAChB,iBAAiB,cAAc,OAAO,UAAU;IAChD;IACA,aAAa;IACb,kBAAkB,UAAU,UAAU;IACvC;IACD;EAIA,oBAAoB,YAAY;EAChC,YAAY;GAAE;GAAM,IAAI;GAAK;EAC9B;;AAOH,eAAsB,oBACpB,SACA,QAC8B;CAC9B,MAAM,UAAU,OAAO,YAAY;CACnC,MAAM,cAAc,OAAO,gBAAgB;CAC3C,MAAM,eAAe,OAAO,iBAAiB;AAI7C,KAAI,EAFmB,OAAO,cAAc,GAG1C,OAAM,IAAI,MACR,+EACD;CAGH,MAAM,cAAc,MAAM,eAAe,SAAS,cAAc,SAAS,YAAY;CAErF,MAAM,WAAW,YAAY,OAAO,SAAS,MAAM,EAAE,MAAM,KAAK,MAAM,EAAE,KAAK,CAAC;CAE9E,MAAM,qBAAqB,MAAM,QAAQ,4BAA4B,UAAU,OAAO,WAAW;CAGjG,MAAM,WAAW,MAAM,QAAQ,qBAAqB,SAAS;CAC7D,MAAM,eAAe,IAAI,IAAoB,SAAS,KAAI,MAAK,CAAC,EAAE,WAAW,EAAE,UAAU,CAAC,CAAC;CAE3F,MAAM,WAA0B,YAAY,OAAO,KAAK,UAAU;EAChE,MAAM,YAAY,MAAM,MAAM,KAAK,MAAM,EAAE,KAAK;EAEhD,MAAM,sBAAsB,MAAM,MAAM,KAAK,OAAO;GAClD,YAAY,EAAE;GACd,OAAO,EAAE,SAAS,EAAE,KAAK,MAAM,IAAI,CAAC,KAAK,IAAI,EAAE;GAC/C,YAAY,aAAa,IAAI,EAAE,KAAK,IAAI;GACzC,EAAE;EAEH,MAAM,aAAa,MAAM;EAEzB,MAAM,EAAE,UAAU,WAAW,0BAC3B,WACA,mBACD;AAED,SAAO;GACL,IAAI,MAAM;GACV,OAAO,MAAM;GACb,MAAM,MAAM;GACZ,kBAAkB,MAAM;GACxB,aAAa;GACb,cAAc,MAAM;GACpB,2BAA2B;GAC3B,yBAAyB;GACzB,oBAAoB,MAAM;GAC1B,eAAe;GACf,OAAO;GACR;GACD;AAEF,UAAS,MAAM,GAAG,MAAM,EAAE,OAAO,EAAE,KAAK;AAExC,QAAO;EACL,UAAU,SAAS,MAAM,GAAG,YAAY;EACxC,sBAAsB,YAAY;EAClC,aAAa,YAAY;EAC1B"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"config-BbLFD7Uf.mjs","names":[],"sources":["../src/notifications/types.ts","../src/tasks/types.ts","../src/config/json-store.ts","../src/config/yaml-store.ts","../src/config/main-config.ts","../src/daemon/config.ts"],"sourcesContent":["/**\n * types.ts — Unified Notification Framework type definitions\n *\n * Defines the channel registry, event routing, and configuration schema\n * for PAI's notification subsystem.\n */\n\n// ---------------------------------------------------------------------------\n// Channel identifiers\n// ---------------------------------------------------------------------------\n\nexport type ChannelId = \"ntfy\" | \"whatsapp\" | \"macos\" | \"voice\" | \"cli\";\n\n// ---------------------------------------------------------------------------\n// Notification event types\n// ---------------------------------------------------------------------------\n\n/**\n * The semantic type of a notification event.\n * Used to route events to the appropriate channels.\n */\nexport type NotificationEvent =\n | \"error\"\n | \"progress\"\n | \"completion\"\n | \"info\"\n | \"debug\";\n\n// ---------------------------------------------------------------------------\n// Notification mode\n// ---------------------------------------------------------------------------\n\n/**\n * The current notification mode.\n *\n * - \"auto\" — Use the per-event routing table (default)\n * - \"voice\" — All events go to voice (WhatsApp TTS)\n * - \"whatsapp\" — All events go to WhatsApp text\n * - \"ntfy\" — All events go to ntfy.sh\n * - \"macos\" — All events go to macOS notifications\n * - \"cli\" — All events go to CLI stdout only\n * - \"off\" — Suppress all notifications\n */\nexport type NotificationMode =\n | \"auto\"\n | \"voice\"\n | \"whatsapp\"\n | \"ntfy\"\n | \"macos\"\n | \"cli\"\n | \"off\";\n\n// ---------------------------------------------------------------------------\n// Per-channel configuration\n// ---------------------------------------------------------------------------\n\nexport interface NtfyChannelConfig {\n enabled: boolean;\n /** ntfy.sh topic URL, e.g. \"https://ntfy.sh/my-topic\" */\n url?: string;\n /** ntfy priority: min | low | default | high | urgent */\n priority?: \"min\" | \"low\" | \"default\" | \"high\" | \"urgent\";\n}\n\nexport interface WhatsAppChannelConfig {\n enabled: boolean;\n /** Optional recipient (phone, JID, or contact name). Omit for self-chat. */\n recipient?: string;\n}\n\nexport interface MacOsChannelConfig {\n enabled: boolean;\n}\n\nexport interface VoiceChannelConfig {\n enabled: boolean;\n /** Kokoro voice name, e.g. \"bm_george\", \"af_bella\". Default: \"bm_george\" */\n voiceName?: string;\n}\n\nexport interface CliChannelConfig {\n enabled: boolean;\n}\n\nexport interface ChannelConfigs {\n ntfy: NtfyChannelConfig;\n whatsapp: WhatsAppChannelConfig;\n macos: MacOsChannelConfig;\n voice: VoiceChannelConfig;\n cli: CliChannelConfig;\n}\n\n// ---------------------------------------------------------------------------\n// Routing table\n// ---------------------------------------------------------------------------\n\n/**\n * Maps each event type to the ordered list of channels that should receive it.\n * Only channels that are enabled in `channels` and present in this list are used.\n */\nexport type RoutingTable = {\n [K in NotificationEvent]: ChannelId[];\n};\n\nexport const DEFAULT_ROUTING: RoutingTable = {\n error: [\"whatsapp\", \"macos\", \"ntfy\", \"cli\"],\n completion: [\"whatsapp\", \"macos\", \"ntfy\", \"cli\"],\n info: [\"cli\"],\n progress: [\"cli\"],\n debug: [],\n};\n\n// ---------------------------------------------------------------------------\n// Top-level notification config (embedded in PaiDaemonConfig)\n// ---------------------------------------------------------------------------\n\nexport interface NotificationConfig {\n /** Current routing mode. Default: \"auto\" */\n mode: NotificationMode;\n /** Per-channel configuration */\n channels: ChannelConfigs;\n /** Event → channel routing (used in \"auto\" mode) */\n routing: RoutingTable;\n}\n\nexport const DEFAULT_CHANNELS: ChannelConfigs = {\n ntfy: {\n enabled: false,\n url: undefined,\n priority: \"default\",\n },\n whatsapp: {\n enabled: true,\n recipient: undefined,\n },\n macos: {\n enabled: true,\n },\n voice: {\n enabled: false,\n voiceName: \"bm_george\",\n },\n cli: {\n enabled: true,\n },\n};\n\nexport const DEFAULT_NOTIFICATION_CONFIG: NotificationConfig = {\n mode: \"auto\",\n channels: DEFAULT_CHANNELS,\n routing: DEFAULT_ROUTING,\n};\n\n// ---------------------------------------------------------------------------\n// Notification payload\n// ---------------------------------------------------------------------------\n\nexport interface NotificationPayload {\n /** Semantic event type — used for routing */\n event: NotificationEvent;\n /** The notification message body */\n message: string;\n /** Optional title (used by macOS, ntfy) */\n title?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Provider interface\n// ---------------------------------------------------------------------------\n\nexport interface NotificationProvider {\n readonly channelId: ChannelId;\n /**\n * Send a notification.\n * Returns true on success, false on failure (failure is non-fatal).\n */\n send(payload: NotificationPayload, config: NotificationConfig): Promise<boolean>;\n}\n\n// ---------------------------------------------------------------------------\n// Send result\n// ---------------------------------------------------------------------------\n\nexport interface SendResult {\n channelsAttempted: ChannelId[];\n channelsSucceeded: ChannelId[];\n channelsFailed: ChannelId[];\n mode: NotificationMode;\n}\n","/**\n * types.ts — Task Bus type definitions\n *\n * Defines the provider registry, ownership resolution, and configuration schema\n * for PAI's cross-session task subsystem.\n *\n * The task bus routes work between PAI sessions through an external tracker.\n * A session files a task; a routine reads it later and dispatches it to the\n * session that owns it — spawning one if none is running.\n *\n * See docs/task-bus.md for the architecture and its constraints.\n */\n\n// ---------------------------------------------------------------------------\n// Provider identifiers\n// ---------------------------------------------------------------------------\n\nexport type ProviderId = \"todoist\";\n\n// ---------------------------------------------------------------------------\n// Ownership\n// ---------------------------------------------------------------------------\n\n/**\n * Prefix marking a tracker label as a PAI ownership assertion.\n * A task labelled `pai:acme-api` is owned by the `acme-api` project.\n */\nexport const OWNER_LABEL_PREFIX = \"pai:\";\n\n/**\n * How a task's owner was determined. Recorded so the routine can explain\n * itself, and so a mis-resolution is diagnosable rather than silent.\n *\n * - \"label\" — an explicit `pai:<project>` label (authoritative)\n * - \"container\" — the enclosing sub-project name matched a PAI alias (fallback)\n * - \"none\" — unresolved; the task stays in the findings inbox\n */\nexport type OwnerSource = \"label\" | \"container\" | \"none\";\n\nexport interface TaskOwner {\n /** Resolved PAI project short name, e.g. \"acme-api\". Null when UNROUTED. */\n project: string | null;\n /** Absolute path to the project root. Null when UNROUTED. */\n rootPath: string | null;\n source: OwnerSource;\n /**\n * The raw string that resolution was attempted against, kept for diagnostics\n * when `source` is \"none\" — e.g. a \"Reading List 📚\" container matches no\n * PAI project, which is expected rather than a fault.\n */\n rawHint?: string;\n}\n\n/** An unresolved owner. UNROUTED is a normal state, not an error. */\nexport const UNROUTED: TaskOwner = {\n project: null,\n rootPath: null,\n source: \"none\",\n};\n\n// ---------------------------------------------------------------------------\n// Tasks\n// ---------------------------------------------------------------------------\n\nexport type TaskPriority = \"p1\" | \"p2\" | \"p3\" | \"p4\";\n\nexport interface Task {\n /** Provider-native task ID. Opaque; never parsed. */\n id: string;\n title: string;\n /**\n * Full procedure AND reasoning — enough that the task is actionable months\n * later, or by the user alone, without re-deriving anything. Enforced at\n * filing time rather than left to discipline.\n */\n body: string;\n owner: TaskOwner;\n /** ISO 8601 date or datetime. Null when the task has no due date. */\n due: string | null;\n /**\n * The tracker's own recurrence text, e.g. \"every day at 08:00\". Null for a\n * one-off.\n *\n * Kept verbatim rather than parsed into a rule because it is also the only\n * way to write a recurrence back: Todoist re-parses this string, and it is\n * what lets a due date be restored without destroying the recurrence.\n */\n recurrence?: string | null;\n priority: TaskPriority;\n labels: string[];\n /**\n * Stable reference to the artifact this task is about. Prefer a `hook://`\n * URL over a filesystem path — it survives renames and moves, and opens in\n * DEVONthink To Go on iOS.\n */\n sourceUrl?: string;\n /** True for organizational headers that cannot be completed. */\n isHeader?: boolean;\n}\n\n/** A task being filed. `owner` is a project short name, resolved on write. */\nexport interface NewTask {\n title: string;\n body: string;\n owner?: string | null;\n due?: string;\n priority?: TaskPriority;\n labels?: string[];\n sourceUrl?: string;\n /**\n * Sub-project to file into, created if absent.\n *\n * The convention is one sub-project per PAI project under the bus root. It\n * previously existed only in the shape of the data, so every session had to\n * re-derive it — and a session that inferred cautiously filed flat instead,\n * which is exactly the pile the convention prevents.\n */\n into?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Provider interface\n// ---------------------------------------------------------------------------\n\nexport interface ListOptions {\n /** Only tasks due on or before this ISO date. Omit for all open tasks. */\n dueBefore?: string;\n /** Restrict to one resolved owner. Omit for every owner. */\n owner?: string;\n /** Include tasks that resolved to UNROUTED. Default: true. */\n includeUnrouted?: boolean;\n limit?: number;\n}\n\nexport interface TaskProvider {\n readonly providerId: ProviderId;\n\n /**\n * False when no credential is configured. The bus degrades to a no-op\n * rather than failing — a user without a tracker still gets working PAI.\n */\n isConfigured(): boolean;\n\n listOpen(opts: ListOptions): Promise<Task[]>;\n add(task: NewTask): Promise<Task>;\n complete(id: string): Promise<void>;\n\n /**\n * Rewrite a task's due date through the tracker's natural-language field.\n *\n * Optional, and deliberately expressed as a string rather than a date: a\n * recurring task's schedule and its next occurrence are the same field, so\n * moving the date without the rule silently downgrades a routine to a one-off.\n * A provider that cannot express both at once should not offer this.\n */\n setDue?(id: string, dueString: string): Promise<void>;\n\n /**\n * Sub-projects under the bus root — the set of addresses a task can be filed\n * against, one per session.\n *\n * Optional because it is not universal: a tracker may address work by tag or\n * list rather than by nested project, and forcing a nesting concept onto one\n * that has none would mean faking it. A provider without these simply does\n * not offer session-scoped inboxes, and callers say so rather than failing.\n */\n listSubProjects?(): Promise<Array<{ id: string; name: string }>>;\n findOrCreateSubProject?(name: string): Promise<{ id: string; created: boolean }>;\n\n /**\n * The comment thread on a task, oldest first.\n *\n * Optional because not every tracker has threaded comments. Where it exists,\n * the thread is usually where the reasoning lives — the question, the answer,\n * the correction — and completing the task takes it out of view. That is what\n * the archive exists to keep.\n */\n listComments?(taskId: string): Promise<Array<{ id: string; content: string; postedAt?: string }>>;\n\n /**\n * One task by id, whether open or completed.\n *\n * Needed because archiving runs at or after completion, and a completed task\n * is gone from `listOpen` — which is exactly the moment its discussion stops\n * being visible and most needs keeping.\n */\n getTask?(id: string): Promise<Task | null>;\n}\n\n// ---------------------------------------------------------------------------\n// Configuration\n// ---------------------------------------------------------------------------\n\nexport interface TodoistProviderConfig {\n enabled: boolean;\n /**\n * API token. Resolution order is apiKey → TODOIST_API_KEY env → unconfigured.\n *\n * Never read this from another tool's config file. PAI ships as a product;\n * scraping ~/.claude.json for a key belonging to the Todoist MCP is not\n * acceptable even though the key is sitting there.\n */\n apiKey?: string;\n /**\n * Tracker project ID that roots the bus (the \"Claude 🤖\" project).\n *\n * Stored as an ID, never a name. Todoist's project search silently returns\n * zero results for names containing emoji — resolving by name would report\n * \"no tasks\" instead of failing, which is the exact class of silent failure\n * this subsystem exists to surface.\n */\n rootProjectId?: string;\n /** Section ID for the findings inbox. Tasks land here when UNROUTED. */\n findingsSectionId?: string;\n}\n\nexport interface TaskConfig {\n /** Master switch. When false the bus is inert. */\n enabled: boolean;\n providers: {\n todoist: TodoistProviderConfig;\n };\n /**\n * Dispatch work to the owning session automatically, spawning one if absent.\n * Requires AIBroker. When false — or when AIBroker is unavailable — PAI\n * reports which project owns each task and leaves acting to the user.\n */\n autoDispatch: boolean;\n\n /**\n * Seconds AIBroker may spend on a single dispatch, spawn included.\n *\n * Passed down to the transport so both sides share one deadline. Raise it on\n * a loaded machine where sessions are slow to start accepting input.\n */\n dispatchTimeoutSecs?: number;\n\n /**\n * Project a task goes to when it carries the bare `pai` marker and its\n * location says nothing — an Inbox capture, typically.\n *\n * This is the one thing a task's location cannot express: \"an AI should take\n * this, and I do not know which one yet\". Everything else is answered by the\n * project the task sits in.\n *\n * Unset means such a task stays UNROUTED, which is a legitimate choice: it\n * then surfaces in the findings inbox for triage rather than being guessed at.\n */\n defaultOwner?: string;\n}\n\nexport const DEFAULT_TASK_CONFIG: TaskConfig = {\n enabled: false,\n providers: {\n todoist: {\n enabled: false,\n },\n },\n autoDispatch: false,\n};\n\n// ---------------------------------------------------------------------------\n// Dispatch results\n// ---------------------------------------------------------------------------\n\n/**\n * What happened to one task during a dispatch run.\n *\n * - \"delivered\" — sent to an already-running session, and confirmed submitted\n * - \"queued\" — typed into a live session that was mid-turn, so submission\n * could not be confirmed inside the window. This is delivery:\n * Claude Code holds typed input until the current turn ends.\n * Never retried — the text is already in the input box, so a\n * second attempt is a second copy, not a retry. One trigger\n * arrived three times on 2026-08-01 for exactly that reason.\n * - \"spawned\" — none running; one was launched, came up, and received it\n * - \"unrouted\" — no owner resolved; left in the findings inbox\n * - \"unlaunchable\" — an owner resolved but no PAI alias exists to launch it\n * - \"unreachable\" — a session was launched but never became ready to accept input\n * - \"skipped\" — autoDispatch is off, or no transport; reported only\n *\n * `unlaunchable` and `unreachable` are distinct because the fixes differ:\n * the first is a setup gap (register an alias), the second is a runtime\n * failure (find out why the session did not come up). Collapsing them would\n * send users looking in the wrong place.\n *\n * None of these are errors. A task that could not be delivered is a routing\n * result to report, not an exception to throw.\n */\nexport type DispatchOutcome =\n | \"delivered\"\n | \"queued\"\n | \"spawned\"\n | \"unrouted\"\n | \"unlaunchable\"\n | \"unreachable\"\n | \"skipped\";\n\nexport interface DispatchResult {\n task: Task;\n outcome: DispatchOutcome;\n /** Session the task reached, when it reached one. */\n session?: string;\n /** Why the task did not reach a session. Present on failure outcomes. */\n reason?: string;\n}\n","/**\n * json-store.ts — read/write JSON config files without destroying them\n *\n * The failure this exists to prevent:\n *\n * try { return JSON.parse(read(path)); } catch { return {}; }\n * ... later ...\n * write(path, JSON.stringify(ourData));\n *\n * An unreadable file becomes an empty object, and the next write makes that\n * permanent. Silently, exit code 0. This shape appeared three times in this\n * repo against three different files, and twice in AIBroker.\n *\n * The distinction that matters is between *missing* and *unreadable*:\n *\n * missing — legitimate first run. Start fresh; writing is safe.\n * unreadable — the file exists and we could not parse it. Those bytes are\n * the only copy of something. Never overwrite them.\n *\n * Collapsing the second into the first is the bug.\n *\n * NOT everything deserves this guard. For a transient buffer — an undelivered\n * message queue, a cache — starting fresh IS the correct recovery, and\n * refusing to write would disable the feature permanently. Use `writeJsonAtomic`\n * alone there: it still prevents a crash from truncating a good file, without\n * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n copyFileSync,\n renameSync,\n unlinkSync,\n mkdirSync,\n chmodSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\n\n/**\n * Read a JSON file, distinguishing \"absent\" from \"damaged\".\n *\n * @param path file to read\n * @param label how to name it to the user, e.g. \"~/.claude.json\"\n * @throws if the file exists but cannot be read or parsed\n */\nexport function readJsonStrict(path: string, label = path): Record<string, unknown> {\n if (!existsSync(path)) return {};\n\n let raw: string;\n try {\n raw = readFileSync(path, \"utf8\");\n } catch (e) {\n throw new Error(\n `Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n `Refusing to continue — writing now would replace its contents with ours alone.`\n );\n }\n\n try {\n return JSON.parse(raw) as Record<string, unknown>;\n } catch (e) {\n throw new Error(\n `${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\\n` +\n `Refusing to continue — overwriting it would destroy whatever it holds.\\n` +\n `Repair the file, or move it aside and re-run this command.`\n );\n }\n}\n\n/**\n * Leaf values that mean \"type word\", not data.\n *\n * A config whose top-level keys map to bare type words is a serialized schema,\n * not a configuration — the exact shape found over the live config on\n * 2026-09-18 (399 bytes: `socketPath: string`, `indexIntervalSecs: int`, …).\n * No real config carries three of these at its top level; a dump carries\n * nothing else.\n */\nconst SCHEMA_TYPE_WORDS = new Set([\n \"string\", \"int\", \"integer\", \"number\", \"bool\", \"boolean\",\n \"float\", \"double\", \"object\", \"array\", \"any\", \"unknown\",\n]);\n\nfunction schemaDumpHits(data: Record<string, unknown>): number {\n let hits = 0;\n for (const v of Object.values(data)) {\n if (typeof v === \"string\" && SCHEMA_TYPE_WORDS.has(v.trim())) hits += 1;\n }\n return hits;\n}\n\n/**\n * Write JSON without risking the existing file.\n *\n * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file\n * and renames. Rename is atomic within a filesystem, so a crash mid-write\n * leaves the original intact rather than truncated — which is how these files\n * become corrupt in the first place.\n */\nexport function writeJsonAtomic(\n path: string,\n data: Record<string, unknown>,\n opts: { backup?: boolean; label?: string } = {}\n): void {\n const { backup = true, label = path } = opts;\n if (schemaDumpHits(data) >= 3) {\n throw new Error(\n `${label}: refusing to write — the object looks like a type schema, not values ` +\n `(several top-level keys map to bare type words). That is the corruption ` +\n `signature of 2026-09-18: the caller serialized the schema instead of the ` +\n `config. Nothing was written.`\n );\n }\n const serialized = JSON.stringify(data, null, 2) + \"\\n\";\n\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n\n if (backup && existsSync(path)) {\n try {\n copyFileSync(path, `${path}.bak-pai`);\n chmodSync(`${path}.bak-pai`, 0o600);\n } catch (e) {\n throw new Error(\n `Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n `Refusing to write without a backup.`\n );\n }\n }\n\n const tmp = `${path}.tmp-pai-${process.pid}`;\n try {\n writeFileSync(tmp, serialized, { encoding: \"utf8\", mode: 0o600 });\n renameSync(tmp, path);\n chmodSync(path, 0o600);\n } catch (e) {\n try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best effort */ }\n throw new Error(\n `Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\\n` +\n `The original is unchanged.`\n );\n }\n}\n","/**\n * yaml-store.ts — generic, comment-preserving YAML file helpers shared by\n * every PAI config file that has a YAML form (config.yaml, voices.yaml;\n * workers.yaml keeps its own writer in workers-config.ts because it needs\n * provider-specific key-quoting, but its atomic-write tail is this module's\n * writeYamlFileAtomic).\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n copyFileSync,\n renameSync,\n unlinkSync,\n mkdirSync,\n chmodSync,\n} from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport { Document, YAMLMap, parseDocument, type Node, type Pair, type Scalar } from \"yaml\";\n\nexport class YamlStoreError extends Error {}\n\n/**\n * Validate `text` parses as YAML, back up any existing file to `.bak-pai`,\n * then write via temp file + rename (atomic within a filesystem) at mode\n * 0600 — every PAI-owned YAML file can carry secrets, key or no key.\n */\nexport function writeYamlFileAtomic(path: string, text: string, opts: { label?: string } = {}): void {\n const label = opts.label ?? path;\n const parsed = parseDocument(text);\n if (parsed.errors.length) {\n throw new YamlStoreError(`refusing to write ${label}: ${parsed.errors[0].message} (previous file left unchanged)`);\n }\n\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n if (existsSync(path)) {\n try {\n copyFileSync(path, `${path}.bak-pai`);\n } catch (e) {\n throw new YamlStoreError(\n `Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\\nRefusing to write without a backup.`\n );\n }\n }\n const tmp = `${path}.tmp-pai-${process.pid}`;\n try {\n writeFileSync(tmp, text, { encoding: \"utf8\", mode: 0o600 });\n renameSync(tmp, path);\n chmodSync(path, 0o600);\n } catch (e) {\n try {\n if (existsSync(tmp)) unlinkSync(tmp);\n } catch {\n /* best effort */\n }\n throw new YamlStoreError(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}`);\n }\n}\n\n/** Deep-equal for plain JSON-shaped values (objects/arrays/scalars). */\nexport function deepEqualJson(a: unknown, b: unknown): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction isPlainObject(v: unknown): v is Record<string, unknown> {\n return typeof v === \"object\" && v !== null && !Array.isArray(v);\n}\n\n/**\n * Apply the diff between `before` and `after` (arbitrary nested plain JSON\n * objects) onto a YAML Document, touching only the leaves/keys that actually\n * changed — this is what lets hand-written comments elsewhere in the file\n * survive a `set`/`unset`. Arrays and non-object values are replaced\n * wholesale when they differ; only plain objects are diffed key by key.\n */\nexport function syncPlainObjectIntoYamlDoc(\n doc: Document,\n before: unknown,\n after: unknown,\n path: (string | number)[] = []\n): void {\n if (isPlainObject(after) && isPlainObject(before)) {\n for (const key of Object.keys(after)) {\n syncPlainObjectIntoYamlDoc(doc, before[key], after[key], [...path, key]);\n }\n for (const key of Object.keys(before)) {\n if (!(key in after)) doc.deleteIn([...path, key]);\n }\n return;\n }\n if (after === undefined) {\n if (before !== undefined) doc.deleteIn(path);\n return;\n }\n if (!deepEqualJson(before, after)) {\n doc.setIn(path, after);\n }\n}\n\n/**\n * Attach a `# text` comment directly above the key at `path` inside a YAML\n * Document — used for the annotated section headers `pai config yaml`\n * writes. No-op if the path does not resolve to a mapping entry (e.g. the\n * key was omitted from the document).\n */\nexport function setYamlKeyComment(doc: Document, path: (string | number)[], text: string): void {\n const parentPath = path.slice(0, -1);\n const lastKey = path[path.length - 1];\n const parent = parentPath.length ? doc.getIn(parentPath, true) : doc.contents;\n if (!(parent instanceof YAMLMap)) return;\n const pair = (parent.items as Pair[]).find((p) => (p.key as Scalar)?.value === lastKey);\n if (pair) {\n (pair.key as Node & { commentBefore?: string }).commentBefore = ` ${text}`;\n }\n}\n","/**\n * main-config.ts — the dual-format (config.yaml / config.json) engine behind\n * the main PAI config file. Deliberately has NO dependency on\n * src/daemon/config.ts (which owns path resolution, PaiDaemonConfig and\n * DEFAULTS) so that module can import this one for loadConfig() without a\n * cycle; every path this module touches is passed in explicitly.\n *\n * Precedence, same shape as workers.yaml/workers/config.ts: config.yaml wins\n * when it exists, config.json otherwise. `pai config yaml` (see\n * src/cli/commands/config.ts) performs the one-time JSON→YAML conversion;\n * every writer that goes through readMainConfigRaw/writeMainConfigRaw (see\n * src/daemon/config.ts) keeps working unmodified either way.\n */\n\nimport { existsSync, readFileSync, renameSync, chmodSync } from \"node:fs\";\nimport { dirname, join, basename } from \"node:path\";\nimport { Document, parseDocument } from \"yaml\";\nimport { readJsonStrict, writeJsonAtomic } from \"./json-store.js\";\nimport { writeYamlFileAtomic, syncPlainObjectIntoYamlDoc, deepEqualJson, setYamlKeyComment } from \"./yaml-store.js\";\n\nexport class MainConfigError extends Error {}\n\n/** The config.yaml sibling of a given config.json path (same directory). */\nexport function yamlSiblingPath(jsonPath: string): string {\n return join(dirname(jsonPath), \"config.yaml\");\n}\n\n/** The path a user should be pointed at: config.yaml when it exists (same\n * precedence as readDualFormatConfigRaw), else config.json. */\nexport function resolvedMainConfigPath(jsonPath: string, yamlPath: string = yamlSiblingPath(jsonPath)): string {\n return existsSync(yamlPath) ? yamlPath : jsonPath;\n}\n\n/**\n * Read the raw main config: config.yaml if it exists, else config.json\n * (readJsonStrict — missing is `{}`, damaged throws), matching\n * readWorkersSection's precedence for workers.yaml/JSON.\n */\nexport function readDualFormatConfigRaw(jsonPath: string, yamlPath: string = yamlSiblingPath(jsonPath)): Record<string, unknown> {\n if (existsSync(yamlPath)) {\n let text: string;\n try {\n text = readFileSync(yamlPath, \"utf8\");\n } catch (e) {\n throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);\n }\n const doc = parseDocument(text);\n if (doc.errors.length) {\n throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);\n }\n const parsed = doc.toJS();\n if (parsed === null || parsed === undefined) return {};\n if (typeof parsed !== \"object\" || Array.isArray(parsed)) {\n throw new MainConfigError(`${yamlPath}: top level must be a mapping`);\n }\n return parsed as Record<string, unknown>;\n }\n return readJsonStrict(jsonPath, jsonPath);\n}\n\n/**\n * Write the raw main config back: comment-preserving diff-sync into\n * config.yaml when it exists, else plain writeJsonAtomic to config.json —\n * this is the one seam every writer of the main config (CLI commands,\n * identity, notifications, setup, workers) routes through, so a config.yaml\n * on disk is honored no matter which of them made the change.\n */\nexport function writeDualFormatConfigRaw(\n jsonPath: string,\n raw: Record<string, unknown>,\n yamlPath: string = yamlSiblingPath(jsonPath)\n): void {\n if (existsSync(yamlPath)) {\n let text: string;\n try {\n text = readFileSync(yamlPath, \"utf8\");\n } catch (e) {\n throw new MainConfigError(`Could not read ${yamlPath}: ${e instanceof Error ? e.message : String(e)}`);\n }\n const doc = parseDocument(text);\n if (doc.errors.length) {\n throw new MainConfigError(`${yamlPath}: ${doc.errors[0].message}`);\n }\n const before = (doc.toJS() ?? {}) as Record<string, unknown>;\n syncPlainObjectIntoYamlDoc(doc, before, raw);\n try {\n writeYamlFileAtomic(yamlPath, String(doc), { label: yamlPath });\n } catch (e) {\n throw new MainConfigError(e instanceof Error ? e.message : String(e));\n }\n return;\n }\n writeJsonAtomic(jsonPath, raw, { label: jsonPath });\n}\n\n// ---------------------------------------------------------------------------\n// JSON → YAML migration (\"pai config yaml\")\n// ---------------------------------------------------------------------------\n\n/**\n * Short annotation for every top-level PaiDaemonConfig field, shown as a `#`\n * comment above the field in the generated config.yaml. Mirrors (and must be\n * kept in sync with) the doc comments on PaiDaemonConfig in\n * src/daemon/config.ts — kept as a separate short-form table rather than\n * parsed from the TS source at runtime.\n */\nexport const MAIN_CONFIG_SECTION_COMMENTS: Record<string, string> = {\n socketPath: \"Unix Domain Socket path for daemon IPC.\",\n indexIntervalSecs: \"How often the daemon re-indexes changed files, in seconds.\",\n embedIntervalSecs: \"How often the daemon runs the embedding pass, in seconds.\",\n embedOnStartup:\n \"Run an embed pass 60s after daemon start. Off by default: with a large backlog it makes every restart a CPU storm.\",\n maintenanceHour: \"Local hour (0-23) to anchor the recurring index/embed cycle to, so maintenance runs in a fixed window.\",\n storageBackend: 'Storage backend: \"sqlite\" (default) or \"postgres\".',\n postgres: 'PostgreSQL connection settings, used when storageBackend is \"postgres\".',\n embeddingModel: \"Embedding model name, used for semantic/hybrid search.\",\n logLevel: \"Daemon log level: debug, info, warn, or error.\",\n vaultPath: \"Obsidian vault root path for zettelkasten indexing, if any.\",\n vaultProjectId: \"Registry project_id used for vault chunks in memory_chunks. Default: auto-detected.\",\n notifications: \"Notification subsystem configuration.\",\n search: \"Search defaults, applied when an MCP tool or CLI call doesn't specify one.\",\n tasks: \"Task bus — optional external tracker for cross-session work.\",\n identity:\n 'Who \"me\" is — addresses that count as the user\\'s own. Empty by default and never guessed: nothing is self-addressed until this is set.',\n workers:\n \"Non-provider worker settings (pane, routing, tree, cache keepalive, fallback). Providers/classes/mcp_sets live in workers.yaml, not here.\",\n};\n\n/** Keys whose leading-underscore string value is a JSON stand-in for a\n * comment (e.g. `\"_comment\": \"...\"`, `\"_deliverToNote\": \"...\"`). Stripped\n * from the generated YAML and re-attached as a real `#` comment above the\n * nearest remaining sibling key in the same object. */\nfunction isCommentKey(key: string): boolean {\n return key.startsWith(\"_\");\n}\n\ninterface CollectedComment {\n path: (string | number)[];\n text: string;\n}\n\n/** Recursively strip `_`-prefixed comment keys out of a plain JSON value,\n * returning the cleaned value plus where each stripped comment belongs. */\nfunction stripUnderscoreComments(value: unknown, path: (string | number)[] = []): { cleaned: unknown; comments: CollectedComment[] } {\n if (Array.isArray(value)) {\n const comments: CollectedComment[] = [];\n const cleaned = value.map((v, i) => {\n const r = stripUnderscoreComments(v, [...path, i]);\n comments.push(...r.comments);\n return r.cleaned;\n });\n return { cleaned, comments };\n }\n if (typeof value === \"object\" && value !== null) {\n const obj = value as Record<string, unknown>;\n const cleaned: Record<string, unknown> = {};\n const comments: CollectedComment[] = [];\n const localComments: string[] = [];\n for (const [k, v] of Object.entries(obj)) {\n if (isCommentKey(k)) {\n if (typeof v === \"string\" && v.trim()) localComments.push(v.trim());\n continue;\n }\n const r = stripUnderscoreComments(v, [...path, k]);\n cleaned[k] = r.cleaned;\n comments.push(...r.comments);\n }\n // Attach every local comment above the first remaining key in this\n // object (in source order) — good enough for the JSON-workaround shape\n // this exists to convert (a `_comment` sibling explaining the object).\n const firstKey = Object.keys(cleaned)[0];\n if (localComments.length && firstKey !== undefined) {\n comments.unshift({ path: [...path, firstKey], text: localComments.join(\" \") });\n }\n return { cleaned, comments };\n }\n return { cleaned: value, comments: [] };\n}\n\nexport interface MainConfigMigrateResult {\n yamlPath: string;\n yamlText: string;\n /** Path the pre-migration JSON was renamed to; null on a dry run. */\n backupPath: string | null;\n dryRun: boolean;\n}\n\n/**\n * `pai config yaml`: convert config.json to config.yaml. Strips `_`-prefixed\n * JSON-comment-workaround keys into real `#` comments, adds a short\n * annotation above every top-level section (MAIN_CONFIG_SECTION_COMMENTS),\n * verifies the generated YAML re-parses to the exact same data before\n * writing anything, then renames the JSON aside to\n * `config.json.migrated-<YYYY-MM-DD>` (never deleted). Refuses when\n * config.yaml already exists unless `force`. `dryRun` computes and returns\n * the would-be YAML text without touching either file.\n */\nexport function migrateMainConfigToYaml(\n jsonPath: string,\n opts: { dryRun?: boolean; force?: boolean; yamlPath?: string; sectionComments?: Record<string, string> } = {}\n): MainConfigMigrateResult {\n const yamlPath = opts.yamlPath ?? yamlSiblingPath(jsonPath);\n if (existsSync(yamlPath) && !opts.force) {\n throw new MainConfigError(`${yamlPath} already exists — edit it directly, or pass --force to regenerate it from ${jsonPath}`);\n }\n if (!existsSync(jsonPath)) {\n throw new MainConfigError(`${jsonPath} does not exist — nothing to migrate`);\n }\n\n const raw = readJsonStrict(jsonPath, jsonPath);\n const { cleaned, comments } = stripUnderscoreComments(raw);\n const cleanedObj = cleaned as Record<string, unknown>;\n\n const doc = new Document(cleanedObj);\n const sectionComments = opts.sectionComments ?? MAIN_CONFIG_SECTION_COMMENTS;\n for (const [key, text] of Object.entries(sectionComments)) {\n if (key in cleanedObj) setYamlKeyComment(doc, [key], text);\n }\n for (const c of comments) {\n setYamlKeyComment(doc, c.path, c.text);\n }\n const yamlText = String(doc);\n\n // Round-trip check: the generated YAML must re-parse to exactly the data\n // it was built from before anything is written or renamed.\n const reparsed = parseDocument(yamlText).toJS();\n if (!deepEqualJson(reparsed, cleanedObj)) {\n throw new MainConfigError(\n `${yamlPath}: generated YAML does not re-parse to the same data as ${jsonPath} — aborting, nothing written`\n );\n }\n\n if (opts.dryRun) {\n return { yamlPath, yamlText, backupPath: null, dryRun: true };\n }\n\n writeYamlFileAtomic(yamlPath, yamlText, { label: yamlPath });\n\n const stamp = new Date().toISOString().slice(0, 10);\n const backupPath = join(dirname(jsonPath), `${basename(jsonPath)}.migrated-${stamp}`);\n renameSync(jsonPath, backupPath);\n chmodSync(backupPath, 0o600);\n\n return { yamlPath, yamlText, backupPath, dryRun: false };\n}\n","/**\n * config.ts — Configuration loader for PAI Daemon\n *\n * Loads config from ~/.claude/pai/config.json (the pre-2026-09-19 location\n * was ~/.config/pai/config.json, briefly ~/.claude/pai.json in between —\n * see paiConfigFilePath/migrateConfigFile).\n * Deep-merges with defaults so partial configs work fine.\n * Expands ~ in path values at runtime.\n */\n\nimport { existsSync, mkdirSync, writeFileSync, chmodSync, readdirSync } from \"node:fs\";\nimport { homedir, userInfo } from \"node:os\";\nimport { join, dirname, basename } from \"node:path\";\nimport type { NotificationConfig } from \"../notifications/types.js\";\nimport { DEFAULT_NOTIFICATION_CONFIG } from \"../notifications/types.js\";\nimport type { TaskConfig } from \"../tasks/types.js\";\nimport { DEFAULT_TASK_CONFIG } from \"../tasks/types.js\";\nimport { paiSocketPath } from \"../runtime-paths.js\";\nimport {\n paiHomePath,\n resolvePaiFile,\n migratePaiFile,\n PaiFileMigrationError,\n type MigrateFileResult,\n} from \"../config/pai-home.js\";\nimport {\n yamlSiblingPath,\n readDualFormatConfigRaw,\n writeDualFormatConfigRaw,\n MainConfigError,\n} from \"../config/main-config.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SearchConfig {\n /** Default search mode: 'keyword', 'semantic', or 'hybrid'. Default: 'keyword'. */\n mode: \"keyword\" | \"semantic\" | \"hybrid\";\n /** Enable cross-encoder reranking by default. Default: true. */\n rerank: boolean;\n /** Recency boost half-life in days. 0 = off. Default: 90. */\n recencyBoostDays: number;\n /** Default max results. Default: 10. */\n defaultLimit: number;\n /** Default snippet length for MCP results. Default: 200. */\n snippetLength: number;\n}\n\nexport interface PostgresConfig {\n /** Connection string — if set, overrides individual host/port/etc. fields */\n connectionString?: string;\n /** Postgres host (default: \"localhost\") */\n host?: string;\n /** Postgres port (default: 5432) */\n port?: number;\n /** Postgres database name (default: \"pai\") */\n database?: string;\n /** Postgres user (default: \"pai\") */\n user?: string;\n /** Postgres password (default: \"pai\") */\n password?: string;\n /** Maximum pool connections (default: 5) */\n maxConnections?: number;\n /** Connection timeout in ms (default: 5000) */\n connectionTimeoutMs?: number;\n}\n\n/**\n * Idle-triggered prompt-cache keepalive for interactive Claude Code sessions\n * (see docs/cache-keepalive.md, \"Interactive sessions\"). Distinct from\n * `workers.cacheKeepaliveSecs` (src/workers/config.ts), which re-arms a\n * *worker provider's* cache via trivial worker spawns on a fixed timer: this\n * beats a live interactive session only when it has actually gone idle long\n * enough to risk its 1h ephemeral cache expiring, and only within working\n * hours — a fixed timer would beat while the user is active (no-op, wasted\n * quota) or overnight (never pays back before the next real prompt anyway).\n */\nexport interface SessionsCacheKeepaliveConfig {\n /** Off by default — arming it is the operator's explicit call. */\n enabled: boolean;\n /** Beat a session once it has been idle at least this long. Must be < the\n * provider's cache TTL (60 for the 1h ephemeral cache) or the beat is too\n * late to matter. */\n idleMinutes: number;\n /** Cap on consecutive beats per idle stretch; resets when the user prompts\n * the session again for real (not with the keepalive word itself). */\n maxBeats: number;\n /** Local time window \"HH:MM-HH:MM\" outside which no beats are sent. */\n activeHours: string;\n /** Skip sessions whose context is too small to be worth a beat. */\n minContextTokens: number;\n /** The exact word typed into the session; kept to one word so the\n * UserPromptSubmit hook can recognise it and answer minimally. */\n prompt: string;\n}\n\nexport interface SessionsConfig {\n cacheKeepalive: SessionsCacheKeepaliveConfig;\n}\n\nexport interface PaiDaemonConfig {\n /** Unix Domain Socket path for IPC */\n socketPath: string;\n\n /** Index schedule interval in seconds (default: 300 = 5 minutes) */\n indexIntervalSecs: number;\n\n /** Embedding schedule interval in seconds (default: 600 = 10 minutes) */\n embedIntervalSecs: number;\n /** Run an embed pass 60s after daemon start. Off by default: with a large\n * backlog it makes every restart a CPU storm, and it ignores the interval. */\n embedOnStartup: boolean;\n\n /** Local hour (0-23) to anchor the recurring index/embed cycle to. When unset,\n * the cycle is anchored to daemon start, so a daytime restart pins every\n * later pass to daytime too — a 24h interval does not by itself mean \"at\n * night\". Set this to run maintenance in a fixed window regardless of when\n * the machine last booted. */\n maintenanceHour?: number;\n\n /** Storage backend: \"sqlite\" (default) or \"postgres\" */\n storageBackend: \"sqlite\" | \"postgres\";\n\n /** PostgreSQL connection config (used when storageBackend = \"postgres\") */\n postgres?: PostgresConfig;\n\n /** Embedding model name (used for semantic/hybrid search) */\n embeddingModel: string;\n\n /** Log level */\n logLevel: \"debug\" | \"info\" | \"warn\" | \"error\";\n\n /** Obsidian vault root path for zettelkasten indexing. If set, vault indexing runs alongside project indexing. */\n vaultPath?: string;\n\n /** Registry project_id to use for vault chunks in memory_chunks. Default: auto-detected. */\n vaultProjectId?: number;\n\n /** Notification subsystem configuration */\n notifications: NotificationConfig;\n\n /** Search defaults — applied when MCP tool or CLI doesn't specify a value */\n search: SearchConfig;\n\n /** Task bus — optional external tracker for cross-session work */\n tasks: TaskConfig;\n\n /** Who \"me\" is — addresses that count as the user's own. */\n identity: IdentityConfig;\n\n /** Interactive-session settings (currently just the cache keepalive). */\n sessions: SessionsConfig;\n}\n\n/**\n * The user's own identity, for anything that delivers back to them.\n *\n * This exists so \"my own address\" is a fact the system can check rather than\n * something a model infers from context. An assistant deciding on the spot\n * whether an address looks like the user's is exactly the judgement that should\n * not be re-made per message.\n *\n * Empty by default and never guessed at install time: an empty `selfEmails`\n * means nothing is self-addressed, so anything reading this fails closed.\n */\nexport interface IdentityConfig {\n /**\n * Where digests and \"mail me X\" requests are delivered.\n *\n * Must be a mailbox separate from the account doing the sending. Gmail files\n * a message sent from an account to itself — or to one of its own domain\n * aliases — under Sent only, and it never reaches the inbox. The send reports\n * success, so this fails silently and looks exactly like delivery. Observed\n * 2026-08-01: owner@example.ch → owner@example.de, sent fine, invisible.\n *\n * Where a separate mailbox is not available, deliver by writing the message\n * and adding the INBOX label to it rather than relying on the send path.\n */\n deliverTo?: string;\n\n /**\n * Every address that counts as the user's own.\n *\n * Used as an allowlist by anything that may act without review — outbound\n * mail being the case that motivated it. Membership is the whole test: an\n * address that is not listed is not the user's, however similar it looks.\n * Plus-aliases and domain aliases must be listed explicitly rather than\n * pattern-matched, because the patterns that would match them also match\n * addresses belonging to other people.\n */\n selfEmails: string[];\n\n /** The account used to send on the user's behalf, when one is configured. */\n sendingAccount?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Per-user Postgres isolation\n// ---------------------------------------------------------------------------\n\n/** Derive a per-user Postgres database name: pai_<username> */\nfunction perUserDbName(): string {\n const username = userInfo().username;\n // Sanitize: only allow alphanumeric and underscore for Postgres identifiers\n const safe = username.replace(/[^a-zA-Z0-9_]/g, \"_\").toLowerCase();\n return `pai_${safe}`;\n}\n\n/** Derive the per-user connection string */\nfunction perUserConnectionString(): string {\n const db = perUserDbName();\n return `postgresql://pai:pai@localhost:5432/${db}`;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\n/**\n * idleMinutes=50 sits under the 60-minute ephemeral-1h TTL with margin for\n * scheduler jitter; maxBeats=6 (~5h of coverage at one beat per idle stretch)\n * and activeHours 08:00-22:00 keep an unattended overnight machine from\n * beating all night for a session nobody will return to before the cache\n * would have expired anyway.\n */\nexport const DEFAULT_SESSIONS_CACHE_KEEPALIVE: SessionsCacheKeepaliveConfig = {\n enabled: false,\n idleMinutes: 50,\n maxBeats: 6,\n activeHours: \"08:00-22:00\",\n minContextTokens: 20_000,\n prompt: \"keepalive\",\n};\n\nexport const DEFAULTS: PaiDaemonConfig = {\n socketPath: paiSocketPath(),\n indexIntervalSecs: 300,\n embedIntervalSecs: 600,\n embedOnStartup: false,\n storageBackend: \"sqlite\",\n postgres: {\n connectionString: perUserConnectionString(),\n maxConnections: 5,\n connectionTimeoutMs: 5000,\n },\n embeddingModel: \"Snowflake/snowflake-arctic-embed-m-v1.5\",\n logLevel: \"info\",\n notifications: DEFAULT_NOTIFICATION_CONFIG,\n tasks: DEFAULT_TASK_CONFIG,\n // Deliberately empty. An install must not guess who the user is: a wrong\n // guess here is an address that can be mailed without review.\n identity: { selfEmails: [] },\n sessions: { cacheKeepalive: { ...DEFAULT_SESSIONS_CACHE_KEEPALIVE } },\n search: {\n mode: \"keyword\",\n rerank: true,\n recencyBoostDays: 90,\n defaultLimit: 10,\n snippetLength: 200,\n },\n};\n\n/** Config template — generated at runtime so the DB name is per-user */\nfunction configTemplate(): string {\n return `{\n \"socketPath\": \"/tmp/pai.sock\",\n \"indexIntervalSecs\": 300,\n \"embedIntervalSecs\": 600,\n \"storageBackend\": \"sqlite\",\n \"postgres\": {\n \"connectionString\": \"${perUserConnectionString()}\",\n \"maxConnections\": 5,\n \"connectionTimeoutMs\": 5000\n },\n \"embeddingModel\": \"Snowflake/snowflake-arctic-embed-m-v1.5\",\n \"logLevel\": \"info\",\n \"vaultPath\": \"\",\n \"vaultProjectId\": 0,\n \"search\": {\n \"mode\": \"keyword\",\n \"rerank\": true,\n \"recencyBoostDays\": 90,\n \"defaultLimit\": 10,\n \"snippetLength\": 200\n }\n}\n`;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/** Expand a leading ~ to the real home directory */\nexport function expandHome(p: string): string {\n if (p === \"~\" || p.startsWith(\"~/\") || p.startsWith(\"~\\\\\")) {\n return join(homedir(), p.slice(1));\n }\n return p;\n}\n\n/** Canonical location since 2026-09-19: under the PAI_HOME namespace dir,\n * so nothing PAI writes can collide with a file Claude Code itself owns. */\nconst NEW_CONFIG_FILE = paiHomePath(\"config.json\");\n\n/** Briefly the canonical location between 2026-09-19's two migrations —\n * read during the transition, never written to again. */\nconst OLD_CONFIG_FILE = join(homedir(), \".claude\", \"pai.json\");\n\n/** Where the config lived before 2026-09-19 — read during the transition,\n * never written to once NEW_CONFIG_FILE exists (see `pai config migrate`). */\nconst LEGACY_CONFIG_FILE = join(homedir(), \".config\", \"pai\", \"config.json\");\n\n/**\n * The path any read/write of the PAI config actually uses: PAI_CONFIG_FILE\n * (tests, power users) first, else the new PAI_HOME location if it exists,\n * else the most recent old location that is actually on disk (printing a\n * one-time notice), else the new location (the target a first write creates).\n */\nexport function paiConfigFilePath(): string {\n const override = process.env.PAI_CONFIG_FILE;\n if (override) return override;\n return resolvePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], \"pai config migrate\");\n}\n\nexport const CONFIG_FILE = paiConfigFilePath();\nexport const CONFIG_DIR = dirname(CONFIG_FILE);\n\n/** config.yaml — the canonical main config once `pai config yaml` has run,\n * read/written by readMainConfigRaw/writeMainConfigRaw instead of\n * CONFIG_FILE whenever it exists (see src/config/main-config.ts). */\nexport function paiConfigYamlFilePath(): string {\n return yamlSiblingPath(CONFIG_FILE);\n}\n\n/**\n * Read the raw main config object exactly as every non-typed writer\n * (identity, notifications, obsidian, setup, workers, memory settings) needs\n * it: config.yaml when it exists, else CONFIG_FILE. `path` overrides the\n * JSON location (tests, or workers/config.ts's parametrized CONFIG_FILE) —\n * its YAML sibling (same dir, `config.yaml`) is what gets preferred.\n */\nexport function readMainConfigRaw(path: string = CONFIG_FILE): Record<string, unknown> {\n try {\n return readDualFormatConfigRaw(path);\n } catch (e) {\n throw e instanceof MainConfigError ? new Error(e.message) : e;\n }\n}\n\n/**\n * Write the raw main config object back through the same seam: a\n * comment-preserving sync into config.yaml when it exists, else a plain\n * writeJsonAtomic to `path`. Every writer of the main config must call this\n * instead of touching CONFIG_FILE directly, so a config.yaml on disk is\n * honored no matter which of them made the change.\n */\nexport function writeMainConfigRaw(raw: Record<string, unknown>, path: string = CONFIG_FILE): void {\n try {\n writeDualFormatConfigRaw(path, raw);\n } catch (e) {\n throw e instanceof MainConfigError ? new Error(e.message) : e;\n }\n}\n\nexport const ConfigMigrationError = PaiFileMigrationError;\nexport type ConfigMigrateResult = MigrateFileResult;\n\n/**\n * `pai config migrate`: move config.json (from ~/.claude/pai.json or\n * ~/.config/pai/config.json, whichever is found) to ~/.claude/pai/config.json\n * byte-for-byte, verify the copy, then rename the old file aside as\n * config.json.migrated-<YYYYMMDD> (never deleted).\n */\nexport function migrateConfigFile(opts: { dryRun?: boolean } = {}): ConfigMigrateResult {\n const result = migratePaiFile(NEW_CONFIG_FILE, [OLD_CONFIG_FILE, LEGACY_CONFIG_FILE], opts);\n if (!result.dryRun && result.fromPath) {\n // config.json can hold secrets (e.g. the Postgres password) — lock down\n // both the new location and the `.migrated-<stamp>` copy left behind.\n if (existsSync(result.toPath)) chmodSync(result.toPath, 0o600);\n const oldDir = dirname(result.fromPath);\n const oldName = basename(result.fromPath);\n for (const entry of readdirSync(oldDir)) {\n if (entry.startsWith(`${oldName}.migrated-`)) chmodSync(join(oldDir, entry), 0o600);\n }\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Deep merge (handles nested objects, not arrays)\n// ---------------------------------------------------------------------------\n\nfunction deepMerge<T extends object>(\n target: T,\n source: Record<string, unknown>\n): T {\n const result = { ...target };\n for (const key of Object.keys(source)) {\n const srcVal = source[key];\n if (srcVal === undefined || srcVal === null) continue;\n const tgtVal = (target as Record<string, unknown>)[key];\n if (\n typeof srcVal === \"object\" &&\n !Array.isArray(srcVal) &&\n typeof tgtVal === \"object\" &&\n tgtVal !== null &&\n !Array.isArray(tgtVal)\n ) {\n (result as Record<string, unknown>)[key] = deepMerge(\n tgtVal as object,\n srcVal as Record<string, unknown>\n );\n } else {\n (result as Record<string, unknown>)[key] = srcVal;\n }\n }\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// Config loader\n// ---------------------------------------------------------------------------\n\n/**\n * Load configuration: config.yaml (see paiConfigYamlFilePath) if it exists,\n * else CONFIG_FILE (see paiConfigFilePath), else defaults. Returns defaults\n * deep-merged with any values found in the file.\n */\nexport function loadConfig(): PaiDaemonConfig {\n if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) {\n return { ...DEFAULTS };\n }\n\n let parsed: Record<string, unknown>;\n try {\n parsed = readMainConfigRaw();\n } catch (e) {\n process.stderr.write(\n `[pai-daemon] Could not read config: ${e instanceof Error ? e.message : String(e)}\\n`\n );\n return { ...DEFAULTS };\n }\n\n // Compat: config.json may use \"obsidianVaultPath\" (legacy key) instead of \"vaultPath\".\n // Map it across so the daemon picks it up correctly.\n if (parsed.obsidianVaultPath && !parsed.vaultPath) {\n parsed.vaultPath = parsed.obsidianVaultPath;\n process.stderr.write(\n `[pai-daemon] Config: mapped obsidianVaultPath → vaultPath (${parsed.vaultPath})\\n`\n );\n }\n\n return deepMerge(DEFAULTS, parsed);\n}\n\n/**\n * Ensure CONFIG_DIR exists and write a default config template to CONFIG_FILE\n * if none exists yet. Call this only from the `serve` command.\n */\nexport function ensureConfigDir(): void {\n if (!existsSync(CONFIG_DIR)) {\n mkdirSync(CONFIG_DIR, { recursive: true });\n process.stderr.write(\n `[pai-daemon] Created config directory: ${CONFIG_DIR}\\n`\n );\n }\n\n if (!existsSync(CONFIG_FILE) && !existsSync(paiConfigYamlFilePath())) {\n try {\n writeFileSync(CONFIG_FILE, configTemplate(), \"utf-8\");\n process.stderr.write(\n `[pai-daemon] Wrote default config to: ${CONFIG_FILE}\\n`\n );\n } catch (e) {\n process.stderr.write(\n `[pai-daemon] Could not write default config: ${e}\\n`\n );\n }\n }\n}\n"],"mappings":"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"}
|