@tekmidian/pai 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/README.md +19 -0
  3. package/dist/{aibroker-client-BYG-GtAg.mjs → aibroker-client-BpfuGPe5.mjs} +1 -1
  4. package/dist/{aibroker-client-BK1h--W_.mjs → aibroker-client-DRld7g6z.mjs} +2 -2
  5. package/dist/{aibroker-client-BK1h--W_.mjs.map → aibroker-client-DRld7g6z.mjs.map} +1 -1
  6. package/dist/{async-DNVWtQl2.mjs → async-CY_cd_8j.mjs} +5 -4
  7. package/dist/async-CY_cd_8j.mjs.map +1 -0
  8. package/dist/{auto-route-CF8WNaoy.mjs → auto-route-CPlam4iv.mjs} +2 -2
  9. package/dist/{auto-route-CF8WNaoy.mjs.map → auto-route-CPlam4iv.mjs.map} +1 -1
  10. package/dist/{chain-CUfE6WYK.mjs → chain-o-g8AgE3.mjs} +4 -4
  11. package/dist/{chain-CUfE6WYK.mjs.map → chain-o-g8AgE3.mjs.map} +1 -1
  12. package/dist/chunker-BH4i-F2b.mjs +300 -0
  13. package/dist/chunker-BH4i-F2b.mjs.map +1 -0
  14. package/dist/cli/index.mjs +20 -20
  15. package/dist/cli/program.mjs +20 -20
  16. package/dist/{clusters-Ci1mseh2.mjs → clusters-DsMf20PP.mjs} +2 -2
  17. package/dist/{clusters-Ci1mseh2.mjs.map → clusters-DsMf20PP.mjs.map} +1 -1
  18. package/dist/{config-CQCmH_gV.mjs → config-2wkz744W.mjs} +3 -3
  19. package/dist/{config-CQCmH_gV.mjs.map → config-2wkz744W.mjs.map} +1 -1
  20. package/dist/{config-DjXq_W42.mjs → config-CUTg6VDq.mjs} +1 -1
  21. package/dist/{context-handover-cache-DFNnlWv2.mjs → context-handover-cache-BOpjLsKe.mjs} +6 -6
  22. package/dist/{context-handover-cache-DFNnlWv2.mjs.map → context-handover-cache-BOpjLsKe.mjs.map} +1 -1
  23. package/dist/daemon/index.mjs +16 -16
  24. package/dist/{daemon-Cex3dT9f.mjs → daemon-BYieXduQ.mjs} +31 -30
  25. package/dist/{daemon-Cex3dT9f.mjs.map → daemon-BYieXduQ.mjs.map} +1 -1
  26. package/dist/daemon-mcp/index.mjs +20 -11
  27. package/dist/daemon-mcp/index.mjs.map +1 -1
  28. package/dist/daemon-q3xjHwa4.mjs +19 -0
  29. package/dist/{detector-3g7XuU1r.mjs → detector-BAlmrLQ0.mjs} +2 -2
  30. package/dist/{detector-3g7XuU1r.mjs.map → detector-BAlmrLQ0.mjs.map} +1 -1
  31. package/dist/detector-C-YmZsOi.mjs +3 -0
  32. package/dist/{embeddings-DfrYNIng.mjs → embeddings-DK9XfQic.mjs} +1 -1
  33. package/dist/{embeddings-DfrYNIng.mjs.map → embeddings-DK9XfQic.mjs.map} +1 -1
  34. package/dist/{embeddings-BNv3TQC5.mjs → embeddings-Xg8XLf2K.mjs} +1 -1
  35. package/dist/{factory-UO9_DFMN.mjs → factory-BMK0tC1b.mjs} +1 -1
  36. package/dist/{factory-BozZa81q.mjs → factory-C4We3xR_.mjs} +13 -13
  37. package/dist/{factory-BozZa81q.mjs.map → factory-C4We3xR_.mjs.map} +1 -1
  38. package/dist/{fallback-ktbR0aR7.mjs → fallback-ECiqCuh6.mjs} +5 -5
  39. package/dist/{fallback-ktbR0aR7.mjs.map → fallback-ECiqCuh6.mjs.map} +1 -1
  40. package/dist/federation-db-BZ8PyxFe.mjs +3 -0
  41. package/dist/{federation-db-w8e-sKjp.mjs → federation-db-CUgy0WYs.mjs} +2 -2
  42. package/dist/{federation-db-w8e-sKjp.mjs.map → federation-db-CUgy0WYs.mjs.map} +1 -1
  43. package/dist/{helpers-Dj-B314s.mjs → helpers-BRCJg0G3.mjs} +2 -208
  44. package/dist/helpers-BRCJg0G3.mjs.map +1 -0
  45. package/dist/index.d.mts +26 -8
  46. package/dist/index.d.mts.map +1 -1
  47. package/dist/index.mjs +5 -4
  48. package/dist/indexer-backend-CJ0RGgMz.mjs +5 -0
  49. package/dist/{ipc-client-Dasm515c.mjs → ipc-client-EhWY8XL0.mjs} +2 -2
  50. package/dist/{ipc-client-Dasm515c.mjs.map → ipc-client-EhWY8XL0.mjs.map} +1 -1
  51. package/dist/{kg-entity-Cj8Q6YmU.mjs → kg-entity-1uqCnk4u.mjs} +1 -1
  52. package/dist/{kg-entity-Cj8Q6YmU.mjs.map → kg-entity-1uqCnk4u.mjs.map} +1 -1
  53. package/dist/{latent-ideas-BRy6HNwg.mjs → latent-ideas-CSuKfiq3.mjs} +4 -4
  54. package/dist/{latent-ideas-BRy6HNwg.mjs.map → latent-ideas-CSuKfiq3.mjs.map} +1 -1
  55. package/dist/{link-boost-BptbAPuv.mjs → link-boost-BtjzfE3c.mjs} +1 -1
  56. package/dist/{link-boost-BptbAPuv.mjs.map → link-boost-BtjzfE3c.mjs.map} +1 -1
  57. package/dist/{main-resolver-BIft_FVF.mjs → main-resolver-DyOrwNYw.mjs} +6 -6
  58. package/dist/{main-resolver-BIft_FVF.mjs.map → main-resolver-DyOrwNYw.mjs.map} +1 -1
  59. package/dist/main-resolver-vN09bkXv.mjs +7 -0
  60. package/dist/merge-CXGz5wXz.mjs +3 -0
  61. package/dist/{merge-xoyfZQ4W.mjs → merge-D58n_-LZ.mjs} +1 -1
  62. package/dist/{merge-xoyfZQ4W.mjs.map → merge-D58n_-LZ.mjs.map} +1 -1
  63. package/dist/{neighborhood-D8uscnGy.mjs → neighborhood-DNuRelRB.mjs} +2 -2
  64. package/dist/{neighborhood-D8uscnGy.mjs.map → neighborhood-DNuRelRB.mjs.map} +1 -1
  65. package/dist/{note-context-CgffaUMh.mjs → note-context-D9JZ4-7o.mjs} +1 -1
  66. package/dist/{note-context-CgffaUMh.mjs.map → note-context-D9JZ4-7o.mjs.map} +1 -1
  67. package/dist/{pai-home-CeOIYltV.mjs → pai-home-UncxWxlX.mjs} +1 -1
  68. package/dist/{pai-home-CeOIYltV.mjs.map → pai-home-UncxWxlX.mjs.map} +1 -1
  69. package/dist/{planner-DAQMTMjP.mjs → planner-DgC3oSvb.mjs} +7 -7
  70. package/dist/{planner-DAQMTMjP.mjs.map → planner-DgC3oSvb.mjs.map} +1 -1
  71. package/dist/{postgres-CxD6_Dca.mjs → postgres-CcsRKir-.mjs} +6 -5
  72. package/dist/{postgres-CxD6_Dca.mjs.map → postgres-CcsRKir-.mjs.map} +1 -1
  73. package/dist/{program-De1GVVM4.mjs → program-JhF2GgJY.mjs} +66 -66
  74. package/dist/program-JhF2GgJY.mjs.map +1 -0
  75. package/dist/query-feedback-BPHFFSu1.mjs +3 -0
  76. package/dist/{query-feedback-xz5axTj3.mjs → query-feedback-BPa0dISE.mjs} +2 -2
  77. package/dist/{query-feedback-xz5axTj3.mjs.map → query-feedback-BPa0dISE.mjs.map} +1 -1
  78. package/dist/{registry-db-Bkpbp7Gf.mjs → registry-db-DCzdI4sC.mjs} +2 -2
  79. package/dist/{registry-db-Bkpbp7Gf.mjs.map → registry-db-DCzdI4sC.mjs.map} +1 -1
  80. package/dist/registry-db-Hphlj3H4.mjs +3 -0
  81. package/dist/{registry-postgres-B07-hmn8.mjs → registry-postgres-BUnkHKYs.mjs} +2 -2
  82. package/dist/{registry-postgres-B07-hmn8.mjs.map → registry-postgres-BUnkHKYs.mjs.map} +1 -1
  83. package/dist/{registry-sqlite-CkGND_tm.mjs → registry-sqlite-CKYNwkUv.mjs} +2 -2
  84. package/dist/{registry-sqlite-CkGND_tm.mjs.map → registry-sqlite-CKYNwkUv.mjs.map} +1 -1
  85. package/dist/{reranker-BS5gqGeF.mjs → reranker-CFiEzHQu.mjs} +1 -1
  86. package/dist/{reranker-CRVawoEh.mjs → reranker-DKv80KO5.mjs} +1 -1
  87. package/dist/{reranker-CRVawoEh.mjs.map → reranker-DKv80KO5.mjs.map} +1 -1
  88. package/dist/{router-DjFZIoDl.mjs → router-CGZATlvm.mjs} +1 -1
  89. package/dist/{router-DjFZIoDl.mjs.map → router-CGZATlvm.mjs.map} +1 -1
  90. package/dist/router-CoA8Uy1m.mjs +3 -0
  91. package/dist/{run-CZVCCvXE.mjs → run-W2rV_9j0.mjs} +5 -5
  92. package/dist/{run-CZVCCvXE.mjs.map → run-W2rV_9j0.mjs.map} +1 -1
  93. package/dist/{run-env-BE5xyCwy.mjs → run-env-Br4Bc7Vz.mjs} +3 -3
  94. package/dist/{run-env-BE5xyCwy.mjs.map → run-env-Br4Bc7Vz.mjs.map} +1 -1
  95. package/dist/{runtime-paths-BRwDeBEg.mjs → runtime-paths-QGQJAekd.mjs} +1 -1
  96. package/dist/{runtime-paths-BRwDeBEg.mjs.map → runtime-paths-QGQJAekd.mjs.map} +1 -1
  97. package/dist/{search-BaTbyWjK.mjs → search-BOSphCJ1.mjs} +2 -2
  98. package/dist/{search-BaTbyWjK.mjs.map → search-BOSphCJ1.mjs.map} +1 -1
  99. package/dist/search-Bf3Kub3F.mjs +3 -0
  100. package/dist/{server-BY6AedmC.mjs → server-B71rem4q.mjs} +3 -3
  101. package/dist/{server-BY6AedmC.mjs.map → server-B71rem4q.mjs.map} +1 -1
  102. package/dist/{session-keepalive-CWGzLBVU.mjs → session-keepalive-Dgil9hjw.mjs} +7 -7
  103. package/dist/{session-keepalive-CWGzLBVU.mjs.map → session-keepalive-Dgil9hjw.mjs.map} +1 -1
  104. package/dist/{sources-BBe3AZ-W.mjs → sources-LcGptLA-.mjs} +1 -1
  105. package/dist/{sources-BBe3AZ-W.mjs.map → sources-LcGptLA-.mjs.map} +1 -1
  106. package/dist/{sqlite-CfKfuggn.mjs → sqlite-CAJcw2zL.mjs} +8 -7
  107. package/dist/sqlite-CAJcw2zL.mjs.map +1 -0
  108. package/dist/{state-n13uPK5B.mjs → state-CHltNjXI.mjs} +1 -1
  109. package/dist/{state-n13uPK5B.mjs.map → state-CHltNjXI.mjs.map} +1 -1
  110. package/dist/{state-CwlGAKgF.mjs → state-S9wlKarB.mjs} +1 -1
  111. package/dist/{stop-words-ClCwO8mZ.mjs → stop-words-BdQuaE9K.mjs} +1 -1
  112. package/dist/{stop-words-ClCwO8mZ.mjs.map → stop-words-BdQuaE9K.mjs.map} +1 -1
  113. package/dist/{themes-B2v7RNnA.mjs → themes-D1FQFthd.mjs} +3 -3
  114. package/dist/{themes-B2v7RNnA.mjs.map → themes-D1FQFthd.mjs.map} +1 -1
  115. package/dist/{tools-DFdPbX-X.mjs → tools-BFC-113F.mjs} +84 -68
  116. package/dist/tools-BFC-113F.mjs.map +1 -0
  117. package/dist/tools-BW7OXf-N.mjs +6 -0
  118. package/dist/{trace-BRflVyBi.mjs → trace-IDKK1VFs.mjs} +1 -1
  119. package/dist/{trace-BRflVyBi.mjs.map → trace-IDKK1VFs.mjs.map} +1 -1
  120. package/dist/{vault-indexer-mCFus_AM.mjs → vault-indexer-y6YC2-mh.mjs} +4 -3
  121. package/dist/vault-indexer-y6YC2-mh.mjs.map +1 -0
  122. package/dist/{wakeup-DO1O4udw.mjs → wakeup-CNZQZzsY.mjs} +3 -3
  123. package/dist/{wakeup-DO1O4udw.mjs.map → wakeup-CNZQZzsY.mjs.map} +1 -1
  124. package/dist/work-queue-worker-CkShUa0z.mjs +11 -0
  125. package/dist/{work-queue-worker-BbrC3I_R.mjs → work-queue-worker-Dy5FT2ak.mjs} +4 -4
  126. package/dist/{work-queue-worker-BbrC3I_R.mjs.map → work-queue-worker-Dy5FT2ak.mjs.map} +1 -1
  127. package/dist/{zettelkasten-BPxSOalU.mjs → zettelkasten-Dx-63BEk.mjs} +4 -4
  128. package/dist/{zettelkasten-BPxSOalU.mjs.map → zettelkasten-Dx-63BEk.mjs.map} +1 -1
  129. package/gemini-extension.json +1 -1
  130. package/package.json +1 -1
  131. package/pai-plugin.json +1 -0
  132. package/dist/async-DNVWtQl2.mjs.map +0 -1
  133. package/dist/daemon-C8-uzOuV.mjs +0 -19
  134. package/dist/detector-DgDn56j-.mjs +0 -3
  135. package/dist/federation-db-CJDw_Pwq.mjs +0 -3
  136. package/dist/helpers-Dj-B314s.mjs.map +0 -1
  137. package/dist/indexer-backend-BN9rpp9O.mjs +0 -5
  138. package/dist/main-resolver-C9-poMdi.mjs +0 -7
  139. package/dist/merge-BncjJTfb.mjs +0 -3
  140. package/dist/program-De1GVVM4.mjs.map +0 -1
  141. package/dist/query-feedback-COYCnvHQ.mjs +0 -3
  142. package/dist/registry-db-DwXpf7yF.mjs +0 -3
  143. package/dist/router-C5hPk9YP.mjs +0 -3
  144. package/dist/search-BQHL5wDr.mjs +0 -3
  145. package/dist/sqlite-CfKfuggn.mjs.map +0 -1
  146. package/dist/tools-B52IvaU6.mjs +0 -6
  147. package/dist/tools-DFdPbX-X.mjs.map +0 -1
  148. package/dist/vault-indexer-mCFus_AM.mjs.map +0 -1
  149. package/dist/work-queue-worker-z-AATZ2t.mjs +0 -11
  150. /package/dist/{indexer-backend-Cc-_NK3q.mjs → indexer-backend-DotpxHJd.mjs} +0 -0
@@ -0,0 +1,19 @@
1
+ import "./utils-C9HsYDpO.mjs";
2
+ import "./migrate-BD7D8EEh.mjs";
3
+ import "./factory-C4We3xR_.mjs";
4
+ import "./wakeup-CNZQZzsY.mjs";
5
+ import "./context-handover-cache-BOpjLsKe.mjs";
6
+ import "./config-2wkz744W.mjs";
7
+ import "./run-env-Br4Bc7Vz.mjs";
8
+ import "./run-W2rV_9j0.mjs";
9
+ import "./server-B71rem4q.mjs";
10
+ import "./state-CHltNjXI.mjs";
11
+ import "./aibroker-client-DRld7g6z.mjs";
12
+ import "./session-keepalive-Dgil9hjw.mjs";
13
+ import "./embeddings-DK9XfQic.mjs";
14
+ import { t as serve } from "./daemon-BYieXduQ.mjs";
15
+ import "./router-CGZATlvm.mjs";
16
+ import "./tools-BFC-113F.mjs";
17
+ import "./work-queue-worker-Dy5FT2ak.mjs";
18
+
19
+ export { serve };
@@ -1,4 +1,4 @@
1
- import { i as populateSlugs } from "./search-BaTbyWjK.mjs";
1
+ import { i as populateSlugs } from "./search-BOSphCJ1.mjs";
2
2
 
3
3
  //#region src/topics/detector.ts
4
4
  /**
@@ -62,4 +62,4 @@ async function detectTopicShift(registry, federation, params) {
62
62
 
63
63
  //#endregion
64
64
  export { detectTopicShift as t };
65
- //# sourceMappingURL=detector-3g7XuU1r.mjs.map
65
+ //# sourceMappingURL=detector-BAlmrLQ0.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"detector-3g7XuU1r.mjs","names":[],"sources":["../src/topics/detector.ts"],"sourcesContent":["/**\n * Topic shift detection engine.\n *\n * Accepts a context summary (recent conversation text) and determines whether\n * the conversation has drifted away from the currently-routed project.\n *\n * Algorithm:\n * 1. Run keyword memory_search against the context text (no project filter)\n * 2. Score results by project — sum of BM25 scores per project\n * 3. Compare the top-scoring project against the current project\n * 4. If a different project dominates by more than the confidence threshold,\n * report a topic shift.\n *\n * Design decisions:\n * - Keyword search only (no semantic) — fast, no embedding requirement\n * - Works with or without an active daemon (direct DB access path)\n * - Stateless: callers supply currentProject; detector has no session memory\n * - Minimal: returns a plain result object, not MCP content arrays\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\nimport { populateSlugs } from \"../memory/search.js\";\nimport type { SearchResult } from \"../memory/search.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicCheckParams {\n /** Recent conversation context (a few sentences or tool call summaries) */\n context: string;\n /** The project slug the session is currently routed to. May be null/empty. */\n currentProject?: string;\n /**\n * Minimum confidence [0,1] to declare a shift. Default: 0.6.\n * Higher = less sensitive, fewer false positives.\n */\n threshold?: number;\n /**\n * Maximum results to draw from memory search (candidates). Default: 20.\n * More candidates = more accurate scoring, slightly slower.\n */\n candidates?: number;\n}\n\nexport interface TopicCheckResult {\n /** Whether a significant topic shift was detected. */\n shifted: boolean;\n /** The project slug the session is currently routed to (echoed from input). */\n currentProject: string | null;\n /** The project slug that best matches the context, or null if no clear match. */\n suggestedProject: string | null;\n /**\n * Confidence score for the suggested project [0,1].\n * Represents the fraction of total score mass held by the top project.\n * 1.0 = all matching chunks belong to one project.\n * 0.5 = two projects are equally matched.\n */\n confidence: number;\n /** Number of memory chunks that contributed to scoring. */\n chunkCount: number;\n /** Top-3 scoring projects with their normalised scores (for debugging). */\n topProjects: Array<{ slug: string; score: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Core algorithm\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether the provided context text best matches a different project\n * than the session's current routing.\n */\nexport async function detectTopicShift(\n registry: RegistryBackend,\n federation: StorageBackend,\n params: TopicCheckParams\n): Promise<TopicCheckResult> {\n const threshold = params.threshold ?? 0.6;\n const candidates = params.candidates ?? 20;\n const currentProject = params.currentProject?.trim() || null;\n\n if (!params.context || params.context.trim().length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // -------------------------------------------------------------------------\n // Run memory search across ALL projects (no project filter)\n // -------------------------------------------------------------------------\n\n const results: SearchResult[] = await federation.searchKeyword(params.context, {\n maxResults: candidates,\n });\n\n if (results.length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // Populate project slugs from the registry\n const withSlugs = await populateSlugs(results, registry);\n\n // -------------------------------------------------------------------------\n // Score projects by summing BM25 scores of matching chunks\n // -------------------------------------------------------------------------\n\n const projectScores = new Map<string, number>();\n\n for (const r of withSlugs) {\n const slug = r.projectSlug;\n if (!slug) continue;\n projectScores.set(slug, (projectScores.get(slug) ?? 0) + r.score);\n }\n\n if (projectScores.size === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: withSlugs.length,\n topProjects: [],\n };\n }\n\n // Sort by total score descending\n const ranked = Array.from(projectScores.entries())\n .sort((a, b) => b[1] - a[1]);\n\n const totalScore = ranked.reduce((sum, [, s]) => sum + s, 0);\n\n // Top-3 for reporting (normalised to [0,1] fraction of total mass)\n const topProjects = ranked.slice(0, 3).map(([slug, score]) => ({\n slug,\n score: totalScore > 0 ? score / totalScore : 0,\n }));\n\n const topSlug = ranked[0][0];\n const topRawScore = ranked[0][1];\n const confidence = totalScore > 0 ? topRawScore / totalScore : 0;\n\n // -------------------------------------------------------------------------\n // Determine if a shift occurred\n // -------------------------------------------------------------------------\n\n // A shift is detected when:\n // 1. confidence >= threshold (the top project dominates)\n // 2. The top project is different from currentProject\n // 3. There is a currentProject to compare against\n // (if no current project, we still return the best match but no \"shift\")\n\n const isDifferent =\n currentProject !== null &&\n topSlug !== currentProject;\n\n const shifted = isDifferent && confidence >= threshold;\n\n return {\n shifted,\n currentProject,\n suggestedProject: topSlug,\n confidence,\n chunkCount: withSlugs.length,\n topProjects,\n };\n}\n"],"mappings":";;;;;;;AA0EA,eAAsB,iBACpB,UACA,YACA,QAC2B;CAC3B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,iBAAiB,OAAO,gBAAgB,MAAM,IAAI;AAExD,KAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,MAAM,CAAC,WAAW,EACtD,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAOH,MAAM,UAA0B,MAAM,WAAW,cAAc,OAAO,SAAS,EAC7E,YAAY,YACb,CAAC;AAEF,KAAI,QAAQ,WAAW,EACrB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAIH,MAAM,YAAY,MAAM,cAAc,SAAS,SAAS;CAMxD,MAAM,gCAAgB,IAAI,KAAqB;AAE/C,MAAK,MAAM,KAAK,WAAW;EACzB,MAAM,OAAO,EAAE;AACf,MAAI,CAAC,KAAM;AACX,gBAAc,IAAI,OAAO,cAAc,IAAI,KAAK,IAAI,KAAK,EAAE,MAAM;;AAGnE,KAAI,cAAc,SAAS,EACzB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY,UAAU;EACtB,aAAa,EAAE;EAChB;CAIH,MAAM,SAAS,MAAM,KAAK,cAAc,SAAS,CAAC,CAC/C,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG;CAE9B,MAAM,aAAa,OAAO,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAG5D,MAAM,cAAc,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,YAAY;EAC7D;EACA,OAAO,aAAa,IAAI,QAAQ,aAAa;EAC9C,EAAE;CAEH,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,cAAc,OAAO,GAAG;CAC9B,MAAM,aAAa,aAAa,IAAI,cAAc,aAAa;AAkB/D,QAAO;EACL,SANA,mBAAmB,QACnB,YAAY,kBAEiB,cAAc;EAI3C;EACA,kBAAkB;EAClB;EACA,YAAY,UAAU;EACtB;EACD"}
1
+ {"version":3,"file":"detector-BAlmrLQ0.mjs","names":[],"sources":["../src/topics/detector.ts"],"sourcesContent":["/**\n * Topic shift detection engine.\n *\n * Accepts a context summary (recent conversation text) and determines whether\n * the conversation has drifted away from the currently-routed project.\n *\n * Algorithm:\n * 1. Run keyword memory_search against the context text (no project filter)\n * 2. Score results by project — sum of BM25 scores per project\n * 3. Compare the top-scoring project against the current project\n * 4. If a different project dominates by more than the confidence threshold,\n * report a topic shift.\n *\n * Design decisions:\n * - Keyword search only (no semantic) — fast, no embedding requirement\n * - Works with or without an active daemon (direct DB access path)\n * - Stateless: callers supply currentProject; detector has no session memory\n * - Minimal: returns a plain result object, not MCP content arrays\n */\n\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\nimport { populateSlugs } from \"../memory/search.js\";\nimport type { SearchResult } from \"../memory/search.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface TopicCheckParams {\n /** Recent conversation context (a few sentences or tool call summaries) */\n context: string;\n /** The project slug the session is currently routed to. May be null/empty. */\n currentProject?: string;\n /**\n * Minimum confidence [0,1] to declare a shift. Default: 0.6.\n * Higher = less sensitive, fewer false positives.\n */\n threshold?: number;\n /**\n * Maximum results to draw from memory search (candidates). Default: 20.\n * More candidates = more accurate scoring, slightly slower.\n */\n candidates?: number;\n}\n\nexport interface TopicCheckResult {\n /** Whether a significant topic shift was detected. */\n shifted: boolean;\n /** The project slug the session is currently routed to (echoed from input). */\n currentProject: string | null;\n /** The project slug that best matches the context, or null if no clear match. */\n suggestedProject: string | null;\n /**\n * Confidence score for the suggested project [0,1].\n * Represents the fraction of total score mass held by the top project.\n * 1.0 = all matching chunks belong to one project.\n * 0.5 = two projects are equally matched.\n */\n confidence: number;\n /** Number of memory chunks that contributed to scoring. */\n chunkCount: number;\n /** Top-3 scoring projects with their normalised scores (for debugging). */\n topProjects: Array<{ slug: string; score: number }>;\n}\n\n// ---------------------------------------------------------------------------\n// Core algorithm\n// ---------------------------------------------------------------------------\n\n/**\n * Detect whether the provided context text best matches a different project\n * than the session's current routing.\n */\nexport async function detectTopicShift(\n registry: RegistryBackend,\n federation: StorageBackend,\n params: TopicCheckParams\n): Promise<TopicCheckResult> {\n const threshold = params.threshold ?? 0.6;\n const candidates = params.candidates ?? 20;\n const currentProject = params.currentProject?.trim() || null;\n\n if (!params.context || params.context.trim().length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // -------------------------------------------------------------------------\n // Run memory search across ALL projects (no project filter)\n // -------------------------------------------------------------------------\n\n const results: SearchResult[] = await federation.searchKeyword(params.context, {\n maxResults: candidates,\n });\n\n if (results.length === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: 0,\n topProjects: [],\n };\n }\n\n // Populate project slugs from the registry\n const withSlugs = await populateSlugs(results, registry);\n\n // -------------------------------------------------------------------------\n // Score projects by summing BM25 scores of matching chunks\n // -------------------------------------------------------------------------\n\n const projectScores = new Map<string, number>();\n\n for (const r of withSlugs) {\n const slug = r.projectSlug;\n if (!slug) continue;\n projectScores.set(slug, (projectScores.get(slug) ?? 0) + r.score);\n }\n\n if (projectScores.size === 0) {\n return {\n shifted: false,\n currentProject,\n suggestedProject: null,\n confidence: 0,\n chunkCount: withSlugs.length,\n topProjects: [],\n };\n }\n\n // Sort by total score descending\n const ranked = Array.from(projectScores.entries())\n .sort((a, b) => b[1] - a[1]);\n\n const totalScore = ranked.reduce((sum, [, s]) => sum + s, 0);\n\n // Top-3 for reporting (normalised to [0,1] fraction of total mass)\n const topProjects = ranked.slice(0, 3).map(([slug, score]) => ({\n slug,\n score: totalScore > 0 ? score / totalScore : 0,\n }));\n\n const topSlug = ranked[0][0];\n const topRawScore = ranked[0][1];\n const confidence = totalScore > 0 ? topRawScore / totalScore : 0;\n\n // -------------------------------------------------------------------------\n // Determine if a shift occurred\n // -------------------------------------------------------------------------\n\n // A shift is detected when:\n // 1. confidence >= threshold (the top project dominates)\n // 2. The top project is different from currentProject\n // 3. There is a currentProject to compare against\n // (if no current project, we still return the best match but no \"shift\")\n\n const isDifferent =\n currentProject !== null &&\n topSlug !== currentProject;\n\n const shifted = isDifferent && confidence >= threshold;\n\n return {\n shifted,\n currentProject,\n suggestedProject: topSlug,\n confidence,\n chunkCount: withSlugs.length,\n topProjects,\n };\n}\n"],"mappings":";;;;;;;AA0EA,eAAsB,iBACpB,UACA,YACA,QAC2B;CAC3B,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,aAAa,OAAO,cAAc;CACxC,MAAM,iBAAiB,OAAO,gBAAgB,MAAM,IAAI;AAExD,KAAI,CAAC,OAAO,WAAW,OAAO,QAAQ,MAAM,CAAC,WAAW,EACtD,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAOH,MAAM,UAA0B,MAAM,WAAW,cAAc,OAAO,SAAS,EAC7E,YAAY,YACb,CAAC;AAEF,KAAI,QAAQ,WAAW,EACrB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY;EACZ,aAAa,EAAE;EAChB;CAIH,MAAM,YAAY,MAAM,cAAc,SAAS,SAAS;CAMxD,MAAM,gCAAgB,IAAI,KAAqB;AAE/C,MAAK,MAAM,KAAK,WAAW;EACzB,MAAM,OAAO,EAAE;AACf,MAAI,CAAC,KAAM;AACX,gBAAc,IAAI,OAAO,cAAc,IAAI,KAAK,IAAI,KAAK,EAAE,MAAM;;AAGnE,KAAI,cAAc,SAAS,EACzB,QAAO;EACL,SAAS;EACT;EACA,kBAAkB;EAClB,YAAY;EACZ,YAAY,UAAU;EACtB,aAAa,EAAE;EAChB;CAIH,MAAM,SAAS,MAAM,KAAK,cAAc,SAAS,CAAC,CAC/C,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,GAAG;CAE9B,MAAM,aAAa,OAAO,QAAQ,KAAK,GAAG,OAAO,MAAM,GAAG,EAAE;CAG5D,MAAM,cAAc,OAAO,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,YAAY;EAC7D;EACA,OAAO,aAAa,IAAI,QAAQ,aAAa;EAC9C,EAAE;CAEH,MAAM,UAAU,OAAO,GAAG;CAC1B,MAAM,cAAc,OAAO,GAAG;CAC9B,MAAM,aAAa,aAAa,IAAI,cAAc,aAAa;AAkB/D,QAAO;EACL,SANA,mBAAmB,QACnB,YAAY,kBAEiB,cAAc;EAI3C;EACA,kBAAkB;EAClB;EACA,YAAY,UAAU;EACtB;EACD"}
@@ -0,0 +1,3 @@
1
+ import { t as detectTopicShift } from "./detector-BAlmrLQ0.mjs";
2
+
3
+ export { detectTopicShift };
@@ -106,4 +106,4 @@ function cosineSimilarity(a, b) {
106
106
 
107
107
  //#endregion
108
108
  export { generateEmbeddings as a, generateEmbedding as i, cosineSimilarity as n, serializeEmbedding as o, deserializeEmbedding as r, configureEmbeddingModel as t };
109
- //# sourceMappingURL=embeddings-DfrYNIng.mjs.map
109
+ //# sourceMappingURL=embeddings-DK9XfQic.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"embeddings-DfrYNIng.mjs","names":[],"sources":["../src/memory/embeddings.ts"],"sourcesContent":["/**\n * Embedding generation for the PAI federation memory engine (Phase 2.5).\n *\n * Uses @huggingface/transformers with the Snowflake/snowflake-arctic-embed-m-v1.5 model\n * (768 dims, q8 quantization, MTEB strong retrieval quality).\n *\n * The model uses CLS pooling (first token) — NOT mean pooling.\n * For retrieval, queries require a prefix: \"Represent this sentence for searching relevant passages: \"\n * Documents should be embedded WITHOUT a prefix.\n *\n * The pipeline is a lazy singleton — loaded on first call, reused thereafter.\n * This avoids loading the heavy ML model on every CLI invocation.\n */\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nexport const EMBEDDING_DIM = 768;\nconst DEFAULT_EMBEDDING_MODEL = \"Snowflake/snowflake-arctic-embed-m-v1.5\";\n\n/** Query prefix required by Snowflake Arctic Embed for retrieval tasks. */\nconst QUERY_PREFIX = \"Represent this sentence for searching relevant passages: \";\n\n// ---------------------------------------------------------------------------\n// Lazy pipeline singleton\n// ---------------------------------------------------------------------------\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet _embeddingPipeline: any = null;\nlet _currentModel: string | null = null;\n\n/**\n * Configure the embedding model to use.\n * Must be called before the first generateEmbedding() call.\n * If the pipeline is already loaded with a different model, it will be reloaded.\n *\n * @param model HuggingFace model ID (e.g. \"Snowflake/snowflake-arctic-embed-m-v1.5\").\n * Pass undefined or empty string to use the default model.\n */\nexport function configureEmbeddingModel(model?: string): void {\n const resolved = model?.trim() || DEFAULT_EMBEDDING_MODEL;\n if (_currentModel !== null && _currentModel !== resolved) {\n // Model changed — force reload on next call\n _embeddingPipeline = null;\n }\n _currentModel = resolved;\n}\n\nasync function getEmbedder() {\n const model = _currentModel ?? DEFAULT_EMBEDDING_MODEL;\n if (!_embeddingPipeline) {\n // Dynamic import to avoid loading the ML runtime on startup\n const { pipeline } = await import(\"@huggingface/transformers\");\n _embeddingPipeline = await pipeline(\n \"feature-extraction\",\n model,\n { dtype: \"q8\" },\n );\n }\n return _embeddingPipeline;\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a normalized 768-dim embedding for the given text.\n *\n * Uses CLS pooling (first token) and L2 normalization (cosine similarity ready).\n *\n * @param text The text to embed.\n * @param isQuery If true, prepend the Snowflake query prefix. Use for search queries.\n * Documents should be embedded without the prefix (default: false).\n */\nexport async function generateEmbedding(text: string, isQuery: boolean = false): Promise<Float32Array> {\n const prefix = isQuery ? QUERY_PREFIX : \"\";\n const input = prefix + text;\n const extractor = await getEmbedder();\n // Snowflake Arctic Embed uses CLS pooling (first token), not mean pooling\n const output = await extractor(input, { pooling: \"cls\", normalize: true });\n return new Float32Array(output.data);\n}\n\n/**\n * Generate normalized embeddings for several documents in one forward pass.\n *\n * The model call dominates embedding cost, and calling it once per chunk leaves\n * most of the available throughput unused: measured on this machine, one-at-a-\n * time runs at ~5 chunks/s, which turns a six-figure backlog into days of\n * work. Batching amortises the per-call overhead across the batch.\n *\n * The pipeline returns one flat buffer for the whole batch, so it is sliced\n * back apart by row. Order is preserved: result[i] corresponds to texts[i].\n *\n * Documents only — queries take the prefixed single-text path, since a search\n * embeds exactly one string and gains nothing here.\n */\nexport async function generateEmbeddings(texts: string[]): Promise<Float32Array[]> {\n if (texts.length === 0) return [];\n if (texts.length === 1) return [await generateEmbedding(texts[0])];\n\n const extractor = await getEmbedder();\n const output = await extractor(texts, { pooling: \"cls\", normalize: true });\n const flat = output.data as Float32Array;\n\n const dim = flat.length / texts.length;\n if (!Number.isInteger(dim)) {\n throw new Error(\n `Batched embedding returned ${flat.length} values for ${texts.length} inputs — not divisible`\n );\n }\n\n const out: Float32Array[] = [];\n for (let i = 0; i < texts.length; i++) {\n // Copy rather than subarray: the caller stores these, and a view would\n // pin the whole batch buffer in memory for the lifetime of one vector.\n out.push(new Float32Array(flat.slice(i * dim, (i + 1) * dim)));\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Serialization helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a Float32Array to a Buffer for storage in a SQLite BLOB column.\n */\nexport function serializeEmbedding(vec: Float32Array): Buffer {\n return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);\n}\n\n/**\n * Deserialize a Buffer (from a SQLite BLOB column) back into a Float32Array.\n */\nexport function deserializeEmbedding(blob: Buffer): Float32Array {\n return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);\n}\n\n// ---------------------------------------------------------------------------\n// Similarity computation\n// ---------------------------------------------------------------------------\n\n/**\n * Compute cosine similarity between two normalized embedding vectors.\n *\n * Since both vectors are already L2-normalized by the embedding model,\n * cosine similarity reduces to a dot product — but we compute the full\n * formula for correctness when embeddings may not be pre-normalized.\n *\n * Returns a value in [-1, 1] where 1 = identical.\n */\nexport function cosineSimilarity(a: Float32Array, b: Float32Array): number {\n let dot = 0;\n let normA = 0;\n let normB = 0;\n for (let i = 0; i < a.length; i++) {\n dot += a[i] * b[i];\n normA += a[i] * a[i];\n normB += b[i] * b[i];\n }\n const denom = Math.sqrt(normA) * Math.sqrt(normB);\n if (denom === 0) return 0;\n return dot / denom;\n}\n"],"mappings":";AAmBA,MAAM,0BAA0B;;AAGhC,MAAM,eAAe;AAOrB,IAAI,qBAA0B;AAC9B,IAAI,gBAA+B;;;;;;;;;AAUnC,SAAgB,wBAAwB,OAAsB;CAC5D,MAAM,WAAW,OAAO,MAAM,IAAI;AAClC,KAAI,kBAAkB,QAAQ,kBAAkB,SAE9C,sBAAqB;AAEvB,iBAAgB;;AAGlB,eAAe,cAAc;CAC3B,MAAM,QAAQ,iBAAiB;AAC/B,KAAI,CAAC,oBAAoB;EAEvB,MAAM,EAAE,aAAa,MAAM,OAAO;AAClC,uBAAqB,MAAM,SACzB,sBACA,OACA,EAAE,OAAO,MAAM,CAChB;;AAEH,QAAO;;;;;;;;;;;AAgBT,eAAsB,kBAAkB,MAAc,UAAmB,OAA8B;CAErG,MAAM,SADS,UAAU,eAAe,MACjB;CAGvB,MAAM,SAAS,OAFG,MAAM,aAAa,EAEN,OAAO;EAAE,SAAS;EAAO,WAAW;EAAM,CAAC;AAC1E,QAAO,IAAI,aAAa,OAAO,KAAK;;;;;;;;;;;;;;;;AAiBtC,eAAsB,mBAAmB,OAA0C;AACjF,KAAI,MAAM,WAAW,EAAG,QAAO,EAAE;AACjC,KAAI,MAAM,WAAW,EAAG,QAAO,CAAC,MAAM,kBAAkB,MAAM,GAAG,CAAC;CAIlE,MAAM,QADS,OADG,MAAM,aAAa,EACN,OAAO;EAAE,SAAS;EAAO,WAAW;EAAM,CAAC,EACtD;CAEpB,MAAM,MAAM,KAAK,SAAS,MAAM;AAChC,KAAI,CAAC,OAAO,UAAU,IAAI,CACxB,OAAM,IAAI,MACR,8BAA8B,KAAK,OAAO,cAAc,MAAM,OAAO,yBACtE;CAGH,MAAM,MAAsB,EAAE;AAC9B,MAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,IAGhC,KAAI,KAAK,IAAI,aAAa,KAAK,MAAM,IAAI,MAAM,IAAI,KAAK,IAAI,CAAC,CAAC;AAEhE,QAAO;;;;;AAUT,SAAgB,mBAAmB,KAA2B;AAC5D,QAAO,OAAO,KAAK,IAAI,QAAQ,IAAI,YAAY,IAAI,WAAW;;;;;AAMhE,SAAgB,qBAAqB,MAA4B;AAC/D,QAAO,IAAI,aAAa,KAAK,QAAQ,KAAK,YAAY,KAAK,aAAa,EAAE;;;;;;;;;;;AAgB5E,SAAgB,iBAAiB,GAAiB,GAAyB;CACzE,IAAI,MAAM;CACV,IAAI,QAAQ;CACZ,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,SAAO,EAAE,KAAK,EAAE;AAChB,WAAS,EAAE,KAAK,EAAE;AAClB,WAAS,EAAE,KAAK,EAAE;;CAEpB,MAAM,QAAQ,KAAK,KAAK,MAAM,GAAG,KAAK,KAAK,MAAM;AACjD,KAAI,UAAU,EAAG,QAAO;AACxB,QAAO,MAAM"}
1
+ {"version":3,"file":"embeddings-DK9XfQic.mjs","names":[],"sources":["../src/memory/embeddings.ts"],"sourcesContent":["/**\n * Embedding generation for the PAI federation memory engine (Phase 2.5).\n *\n * Uses @huggingface/transformers with the Snowflake/snowflake-arctic-embed-m-v1.5 model\n * (768 dims, q8 quantization, MTEB strong retrieval quality).\n *\n * The model uses CLS pooling (first token) — NOT mean pooling.\n * For retrieval, queries require a prefix: \"Represent this sentence for searching relevant passages: \"\n * Documents should be embedded WITHOUT a prefix.\n *\n * The pipeline is a lazy singleton — loaded on first call, reused thereafter.\n * This avoids loading the heavy ML model on every CLI invocation.\n */\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\nexport const EMBEDDING_DIM = 768;\nconst DEFAULT_EMBEDDING_MODEL = \"Snowflake/snowflake-arctic-embed-m-v1.5\";\n\n/** Query prefix required by Snowflake Arctic Embed for retrieval tasks. */\nconst QUERY_PREFIX = \"Represent this sentence for searching relevant passages: \";\n\n// ---------------------------------------------------------------------------\n// Lazy pipeline singleton\n// ---------------------------------------------------------------------------\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nlet _embeddingPipeline: any = null;\nlet _currentModel: string | null = null;\n\n/**\n * Configure the embedding model to use.\n * Must be called before the first generateEmbedding() call.\n * If the pipeline is already loaded with a different model, it will be reloaded.\n *\n * @param model HuggingFace model ID (e.g. \"Snowflake/snowflake-arctic-embed-m-v1.5\").\n * Pass undefined or empty string to use the default model.\n */\nexport function configureEmbeddingModel(model?: string): void {\n const resolved = model?.trim() || DEFAULT_EMBEDDING_MODEL;\n if (_currentModel !== null && _currentModel !== resolved) {\n // Model changed — force reload on next call\n _embeddingPipeline = null;\n }\n _currentModel = resolved;\n}\n\nasync function getEmbedder() {\n const model = _currentModel ?? DEFAULT_EMBEDDING_MODEL;\n if (!_embeddingPipeline) {\n // Dynamic import to avoid loading the ML runtime on startup\n const { pipeline } = await import(\"@huggingface/transformers\");\n _embeddingPipeline = await pipeline(\n \"feature-extraction\",\n model,\n { dtype: \"q8\" },\n );\n }\n return _embeddingPipeline;\n}\n\n// ---------------------------------------------------------------------------\n// Embedding generation\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a normalized 768-dim embedding for the given text.\n *\n * Uses CLS pooling (first token) and L2 normalization (cosine similarity ready).\n *\n * @param text The text to embed.\n * @param isQuery If true, prepend the Snowflake query prefix. Use for search queries.\n * Documents should be embedded without the prefix (default: false).\n */\nexport async function generateEmbedding(text: string, isQuery: boolean = false): Promise<Float32Array> {\n const prefix = isQuery ? QUERY_PREFIX : \"\";\n const input = prefix + text;\n const extractor = await getEmbedder();\n // Snowflake Arctic Embed uses CLS pooling (first token), not mean pooling\n const output = await extractor(input, { pooling: \"cls\", normalize: true });\n return new Float32Array(output.data);\n}\n\n/**\n * Generate normalized embeddings for several documents in one forward pass.\n *\n * The model call dominates embedding cost, and calling it once per chunk leaves\n * most of the available throughput unused: measured on this machine, one-at-a-\n * time runs at ~5 chunks/s, which turns a six-figure backlog into days of\n * work. Batching amortises the per-call overhead across the batch.\n *\n * The pipeline returns one flat buffer for the whole batch, so it is sliced\n * back apart by row. Order is preserved: result[i] corresponds to texts[i].\n *\n * Documents only — queries take the prefixed single-text path, since a search\n * embeds exactly one string and gains nothing here.\n */\nexport async function generateEmbeddings(texts: string[]): Promise<Float32Array[]> {\n if (texts.length === 0) return [];\n if (texts.length === 1) return [await generateEmbedding(texts[0])];\n\n const extractor = await getEmbedder();\n const output = await extractor(texts, { pooling: \"cls\", normalize: true });\n const flat = output.data as Float32Array;\n\n const dim = flat.length / texts.length;\n if (!Number.isInteger(dim)) {\n throw new Error(\n `Batched embedding returned ${flat.length} values for ${texts.length} inputs — not divisible`\n );\n }\n\n const out: Float32Array[] = [];\n for (let i = 0; i < texts.length; i++) {\n // Copy rather than subarray: the caller stores these, and a view would\n // pin the whole batch buffer in memory for the lifetime of one vector.\n out.push(new Float32Array(flat.slice(i * dim, (i + 1) * dim)));\n }\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// Serialization helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Serialize a Float32Array to a Buffer for storage in a SQLite BLOB column.\n */\nexport function serializeEmbedding(vec: Float32Array): Buffer {\n return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);\n}\n\n/**\n * Deserialize a Buffer (from a SQLite BLOB column) back into a Float32Array.\n */\nexport function deserializeEmbedding(blob: Buffer): Float32Array {\n return new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);\n}\n\n// ---------------------------------------------------------------------------\n// Similarity computation\n// ---------------------------------------------------------------------------\n\n/**\n * Compute cosine similarity between two normalized embedding vectors.\n *\n * Since both vectors are already L2-normalized by the embedding model,\n * cosine similarity reduces to a dot product — but we compute the full\n * formula for correctness when embeddings may not be pre-normalized.\n *\n * Returns a value in [-1, 1] where 1 = identical.\n */\nexport function cosineSimilarity(a: Float32Array, b: Float32Array): number {\n let dot = 0;\n let normA = 0;\n let normB = 0;\n for (let i = 0; i < a.length; i++) {\n dot += a[i] * b[i];\n normA += a[i] * a[i];\n normB += b[i] * b[i];\n }\n const denom = Math.sqrt(normA) * Math.sqrt(normB);\n if (denom === 0) return 0;\n return dot / denom;\n}\n"],"mappings":";AAmBA,MAAM,0BAA0B;;AAGhC,MAAM,eAAe;AAOrB,IAAI,qBAA0B;AAC9B,IAAI,gBAA+B;;;;;;;;;AAUnC,SAAgB,wBAAwB,OAAsB;CAC5D,MAAM,WAAW,OAAO,MAAM,IAAI;AAClC,KAAI,kBAAkB,QAAQ,kBAAkB,SAE9C,sBAAqB;AAEvB,iBAAgB;;AAGlB,eAAe,cAAc;CAC3B,MAAM,QAAQ,iBAAiB;AAC/B,KAAI,CAAC,oBAAoB;EAEvB,MAAM,EAAE,aAAa,MAAM,OAAO;AAClC,uBAAqB,MAAM,SACzB,sBACA,OACA,EAAE,OAAO,MAAM,CAChB;;AAEH,QAAO;;;;;;;;;;;AAgBT,eAAsB,kBAAkB,MAAc,UAAmB,OAA8B;CAErG,MAAM,SADS,UAAU,eAAe,MACjB;CAGvB,MAAM,SAAS,OAFG,MAAM,aAAa,EAEN,OAAO;EAAE,SAAS;EAAO,WAAW;EAAM,CAAC;AAC1E,QAAO,IAAI,aAAa,OAAO,KAAK;;;;;;;;;;;;;;;;AAiBtC,eAAsB,mBAAmB,OAA0C;AACjF,KAAI,MAAM,WAAW,EAAG,QAAO,EAAE;AACjC,KAAI,MAAM,WAAW,EAAG,QAAO,CAAC,MAAM,kBAAkB,MAAM,GAAG,CAAC;CAIlE,MAAM,QADS,OADG,MAAM,aAAa,EACN,OAAO;EAAE,SAAS;EAAO,WAAW;EAAM,CAAC,EACtD;CAEpB,MAAM,MAAM,KAAK,SAAS,MAAM;AAChC,KAAI,CAAC,OAAO,UAAU,IAAI,CACxB,OAAM,IAAI,MACR,8BAA8B,KAAK,OAAO,cAAc,MAAM,OAAO,yBACtE;CAGH,MAAM,MAAsB,EAAE;AAC9B,MAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,IAGhC,KAAI,KAAK,IAAI,aAAa,KAAK,MAAM,IAAI,MAAM,IAAI,KAAK,IAAI,CAAC,CAAC;AAEhE,QAAO;;;;;AAUT,SAAgB,mBAAmB,KAA2B;AAC5D,QAAO,OAAO,KAAK,IAAI,QAAQ,IAAI,YAAY,IAAI,WAAW;;;;;AAMhE,SAAgB,qBAAqB,MAA4B;AAC/D,QAAO,IAAI,aAAa,KAAK,QAAQ,KAAK,YAAY,KAAK,aAAa,EAAE;;;;;;;;;;;AAgB5E,SAAgB,iBAAiB,GAAiB,GAAyB;CACzE,IAAI,MAAM;CACV,IAAI,QAAQ;CACZ,IAAI,QAAQ;AACZ,MAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,SAAO,EAAE,KAAK,EAAE;AAChB,WAAS,EAAE,KAAK,EAAE;AAClB,WAAS,EAAE,KAAK,EAAE;;CAEpB,MAAM,QAAQ,KAAK,KAAK,MAAM,GAAG,KAAK,KAAK,MAAM;AACjD,KAAI,UAAU,EAAG,QAAO;AACxB,QAAO,MAAM"}
@@ -1,3 +1,3 @@
1
- import { a as generateEmbeddings, i as generateEmbedding, n as cosineSimilarity, o as serializeEmbedding, r as deserializeEmbedding, t as configureEmbeddingModel } from "./embeddings-DfrYNIng.mjs";
1
+ import { a as generateEmbeddings, i as generateEmbedding, n as cosineSimilarity, o as serializeEmbedding, r as deserializeEmbedding, t as configureEmbeddingModel } from "./embeddings-DK9XfQic.mjs";
2
2
 
3
3
  export { generateEmbedding, generateEmbeddings, serializeEmbedding };
@@ -1,3 +1,3 @@
1
- import { a as getStorageBackend, i as getRegistryBackend, n as createRegistryBackend, r as createStorageBackend, t as closeStorage } from "./factory-BozZa81q.mjs";
1
+ import { a as getStorageBackend, i as getRegistryBackend, n as createRegistryBackend, r as createStorageBackend, t as closeStorage } from "./factory-C4We3xR_.mjs";
2
2
 
3
3
  export { createStorageBackend, getRegistryBackend };
@@ -56,7 +56,7 @@ async function createStorageBackend(config, opts = {}) {
56
56
  * backend on success, or an error string describing why it failed.
57
57
  */
58
58
  async function attemptPostgres(config) {
59
- const { PostgresBackend } = await import("./postgres-CxD6_Dca.mjs");
59
+ const { PostgresBackend } = await import("./postgres-CcsRKir-.mjs");
60
60
  const pgConfig = config.postgres ?? {};
61
61
  let backend = null;
62
62
  try {
@@ -85,8 +85,8 @@ const ESCALATE_AFTER_ATTEMPTS = 5;
85
85
  /** Tell the user the backend is down, through whatever channels are configured. */
86
86
  async function notifyBackendDown(attempts, lastError) {
87
87
  try {
88
- const { routeNotification } = await import("./router-C5hPk9YP.mjs");
89
- const { loadConfig } = await import("./config-DjXq_W42.mjs");
88
+ const { routeNotification } = await import("./router-CoA8Uy1m.mjs");
89
+ const { loadConfig } = await import("./config-CUTg6VDq.mjs");
90
90
  await routeNotification({
91
91
  event: "error",
92
92
  title: "PAI: storage backend unreachable",
@@ -97,8 +97,8 @@ async function notifyBackendDown(attempts, lastError) {
97
97
  /** And say when it comes back, so the alert is not left hanging. */
98
98
  async function notifyBackendRecovered(attempts, since) {
99
99
  try {
100
- const { routeNotification } = await import("./router-C5hPk9YP.mjs");
101
- const { loadConfig } = await import("./config-DjXq_W42.mjs");
100
+ const { routeNotification } = await import("./router-CoA8Uy1m.mjs");
101
+ const { loadConfig } = await import("./config-CUTg6VDq.mjs");
102
102
  const mins = since ? Math.max(1, Math.round((Date.now() - since) / 6e4)) : null;
103
103
  await routeNotification({
104
104
  event: "completion",
@@ -158,8 +158,8 @@ function getSharedPostgresBackend(config, waitForever) {
158
158
  return pgBackendPromise;
159
159
  }
160
160
  async function createSQLiteBackend() {
161
- const { openFederation } = await import("./federation-db-CJDw_Pwq.mjs");
162
- const { SQLiteBackend } = await import("./sqlite-CfKfuggn.mjs");
161
+ const { openFederation } = await import("./federation-db-BZ8PyxFe.mjs");
162
+ const { SQLiteBackend } = await import("./sqlite-CAJcw2zL.mjs");
163
163
  return new SQLiteBackend(openFederation());
164
164
  }
165
165
  /**
@@ -170,14 +170,14 @@ async function createSQLiteBackend() {
170
170
  async function createRegistryBackend(config, opts = {}) {
171
171
  if (config.storageBackend === "postgres") {
172
172
  const storage = await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);
173
- const { PostgresRegistryBackend } = await import("./registry-postgres-B07-hmn8.mjs");
173
+ const { PostgresRegistryBackend } = await import("./registry-postgres-BUnkHKYs.mjs");
174
174
  return new PostgresRegistryBackend(storage.getPool());
175
175
  }
176
176
  return createSQLiteRegistryBackend();
177
177
  }
178
178
  async function createSQLiteRegistryBackend() {
179
- const { openRegistry } = await import("./registry-db-DwXpf7yF.mjs");
180
- const { SQLiteRegistryBackend } = await import("./registry-sqlite-CkGND_tm.mjs");
179
+ const { openRegistry } = await import("./registry-db-Hphlj3H4.mjs");
180
+ const { SQLiteRegistryBackend } = await import("./registry-sqlite-CKYNwkUv.mjs");
181
181
  return new SQLiteRegistryBackend(openRegistry());
182
182
  }
183
183
  /**
@@ -209,7 +209,7 @@ function registerBeforeExit() {
209
209
  */
210
210
  async function getStorageBackend() {
211
211
  if (!storagePromise) storagePromise = (async () => {
212
- const { loadConfig } = await import("./config-DjXq_W42.mjs");
212
+ const { loadConfig } = await import("./config-CUTg6VDq.mjs");
213
213
  registerBeforeExit();
214
214
  return await createStorageBackend(loadConfig());
215
215
  })();
@@ -218,7 +218,7 @@ async function getStorageBackend() {
218
218
  /** Returns the process-wide RegistryBackend, creating it on first call. Same synchronous-caching requirement as getStorageBackend(). */
219
219
  async function getRegistryBackend() {
220
220
  if (!registryPromise) registryPromise = (async () => {
221
- const { loadConfig } = await import("./config-DjXq_W42.mjs");
221
+ const { loadConfig } = await import("./config-CUTg6VDq.mjs");
222
222
  registerBeforeExit();
223
223
  return await createRegistryBackend(loadConfig());
224
224
  })();
@@ -246,4 +246,4 @@ async function closeStorage() {
246
246
 
247
247
  //#endregion
248
248
  export { getStorageBackend as a, getRegistryBackend as i, createRegistryBackend as n, getBackendOutage as o, createStorageBackend as r, humanDuration as s, closeStorage as t };
249
- //# sourceMappingURL=factory-BozZa81q.mjs.map
249
+ //# sourceMappingURL=factory-C4We3xR_.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"factory-BozZa81q.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport type { RegistryBackend } from \"./registry-interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\n/**\n * Shared retry loop: attempt a connection, back off, escalate, repeat — used\n * by both the federation and registry Postgres connectors so the retry/outage\n * behaviour (§6, \"never falls back to SQLite silently\") stays in one place.\n */\nasync function retryConnect<T>(\n attempt: () => Promise<{ backend: T } | { error: string }>,\n waitForever: boolean,\n label: string\n): Promise<T> {\n let attemptN = 0;\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attemptN++;\n const result = await attempt();\n if (\"backend\" in result) {\n if (attemptN > 1) process.stderr.write(`[pai-daemon] Connected to ${label} (after ${attemptN} attempts).\\n`);\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attemptN, outageSince);\n return result.backend;\n }\n\n const lastError = result.error;\n\n if (!waitForever && attemptN > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `${label} unreachable after ${attemptN} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attemptN - 1, 4))\n : CLI_RETRY_DELAYS_MS[attemptN - 1];\n\n process.stderr.write(\n `[pai-daemon] ${label} unavailable (${lastError}). ` +\n `Retry ${attemptN}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: label,\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attemptN,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attemptN === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attemptN, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n return retryConnect(() => attemptPostgres(config), waitForever, \"PostgreSQL backend\");\n}\n\n/**\n * The process's one Postgres connection: storage and registry share a\n * single pg Pool (one connection budget) instead of each opening their own.\n * Whichever of createStorageBackend()/createRegistryBackend() runs first\n * creates it via connectPostgres()'s retry/outage handling; the other\n * reuses its pool through PostgresBackend.getPool(). Reset by closeStorage().\n */\nlet pgBackendPromise: Promise<StorageBackend> | null = null;\n\nfunction getSharedPostgresBackend(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n if (!pgBackendPromise) {\n pgBackendPromise = connectPostgres(config, waitForever);\n }\n return pgBackendPromise;\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"./sqlite/federation-db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n\n/**\n * Create and return the configured RegistryBackend (projects, sessions,\n * tags, aliases, links, compaction_log). Same auto-behaviour and same\n * never-fall-back-to-SQLite rule as createStorageBackend().\n */\nexport async function createRegistryBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<RegistryBackend> {\n if (config.storageBackend === \"postgres\") {\n // Reuses the storage backend's pool (getSharedPostgresBackend) rather\n // than opening a second one against the same database.\n const storage = await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);\n const { PostgresRegistryBackend } = await import(\"./registry-postgres.js\");\n const pool = (storage as unknown as { getPool: () => import(\"pg\").Pool }).getPool();\n return new PostgresRegistryBackend(pool);\n }\n return createSQLiteRegistryBackend();\n}\n\nasync function createSQLiteRegistryBackend(): Promise<RegistryBackend> {\n const { openRegistry } = await import(\"./sqlite/registry-db.js\");\n const { SQLiteRegistryBackend } = await import(\"./registry-sqlite.js\");\n const db = openRegistry();\n return new SQLiteRegistryBackend(db);\n}\n\n/**\n * Process-wide backend accessors.\n *\n * Callers across the codebase must get their StorageBackend/RegistryBackend\n * from here rather than constructing SQLite or Postgres directly — this is\n * the one place that decides which backend a process uses, and the one place\n * that closes it on exit.\n */\n\nlet storagePromise: Promise<StorageBackend> | null = null;\nlet registryPromise: Promise<RegistryBackend> | null = null;\nlet beforeExitRegistered = false;\n\nfunction registerBeforeExit(): void {\n if (beforeExitRegistered) return;\n beforeExitRegistered = true;\n process.once(\"beforeExit\", () => {\n void closeStorage();\n });\n}\n\n/**\n * Returns the process-wide StorageBackend, creating it on first call.\n *\n * The whole body up to and including the `storagePromise = (async () =>\n * ...)()` assignment must run synchronously (no `await` before it) — two\n * calls issued in the same tick (e.g. `Promise.all([get(), get()])`) must\n * both observe the same cached promise rather than each racing to create\n * their own backend.\n */\nexport async function getStorageBackend(): Promise<StorageBackend> {\n if (!storagePromise) {\n storagePromise = (async () => {\n const { loadConfig } = await import(\"../daemon/config.js\");\n registerBeforeExit();\n return await createStorageBackend(loadConfig());\n })();\n }\n return storagePromise;\n}\n\n/** Returns the process-wide RegistryBackend, creating it on first call. Same synchronous-caching requirement as getStorageBackend(). */\nexport async function getRegistryBackend(): Promise<RegistryBackend> {\n if (!registryPromise) {\n registryPromise = (async () => {\n const { loadConfig } = await import(\"../daemon/config.js\");\n registerBeforeExit();\n return await createRegistryBackend(loadConfig());\n })();\n }\n return registryPromise;\n}\n\n/** Closes any backends created via getStorageBackend()/getRegistryBackend(). Idempotent. */\nexport async function closeStorage(): Promise<void> {\n const pending = [storagePromise, registryPromise];\n const pg = pgBackendPromise;\n storagePromise = null;\n registryPromise = null;\n pgBackendPromise = null;\n for (const p of pending) {\n if (!p) continue;\n try {\n const backend = await p;\n // Postgres backends share one pool owned by pgBackendPromise (closed\n // below) — closing them here too would end() it twice.\n if (backend.backendType === \"postgres\") continue;\n await backend.close();\n } catch {\n // A backend that never connected has nothing to close.\n }\n }\n if (pg) {\n try {\n await (await pg).close();\n } catch {\n // Never connected — nothing to close.\n }\n }\n}\n\n/** Test-only: clear the process-wide cache without closing (tests own their own lifecycle). */\nexport function __resetStorageForTests(): void {\n storagePromise = null;\n registryPromise = null;\n pgBackendPromise = null;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"factory-C4We3xR_.mjs","names":[],"sources":["../src/storage/outage.ts","../src/storage/factory.ts"],"sourcesContent":["/**\n * outage.ts — is the storage backend actually reachable right now?\n *\n * The daemon retries a dead backend forever, which is correct: a Postgres\n * container that is down will usually come back, and giving up would lose the\n * work queue. What was wrong is that it did so in complete silence.\n *\n * Observed 2026-07-26: the container was down for roughly two days. The daemon\n * logged \"Postgres unavailable\" 144 times over 36 minutes, the work queue backed\n * up, session notes for the whole period were never written — and\n * `pai daemon status` reported \"Index: idle\" throughout. The one command anyone\n * would run to check said everything was fine.\n *\n * So the outage is recorded where the status command can see it, and escalated\n * once through the notification channels that were already configured and\n * already unused for this.\n */\n\nexport interface BackendOutage {\n backend: string;\n /** When the current run of failures began. */\n since: number;\n /** Consecutive failed attempts so far. */\n attempts: number;\n lastError: string;\n}\n\nlet current: BackendOutage | null = null;\n\n/** Record that the backend is currently unreachable. */\nexport function setBackendOutage(outage: BackendOutage): void {\n current = outage;\n}\n\n/**\n * Record that the backend answered.\n *\n * Called on every successful connection, including the first — so a daemon that\n * never had a problem reports none, and one that recovered stops reporting an\n * outage that has ended.\n */\nexport function clearBackendOutage(): void {\n current = null;\n}\n\n/** The current outage, or null when the backend is answering. */\nexport function getBackendOutage(): BackendOutage | null {\n return current;\n}\n\n/**\n * Human-readable elapsed time, for a status line rather than a log.\n *\n * Exported because the status command needs the duration on its own, to colour\n * it separately from the rest of the sentence. Without this it kept a second\n * copy of the same rounding, which is how two renderings of one outage drift.\n */\nexport function humanDuration(ms: number): string {\n const mins = Math.max(1, Math.round(ms / 60_000));\n return mins < 60 ? `${mins} min` : `${(mins / 60).toFixed(1)} h`;\n}\n\n/** Human-readable duration, for a status line rather than a log. */\nexport function describeOutage(o: BackendOutage, now = Date.now()): string {\n return `${o.backend} unreachable for ${humanDuration(now - o.since)}, ${o.attempts} attempts — ${o.lastError}`;\n}\n","/**\n * Storage backend factory.\n *\n * Reads the daemon config and returns the appropriate StorageBackend.\n *\n * When Postgres is the configured backend we NEVER silently fall back to\n * SQLite — doing so would split the corpus across two databases. Instead:\n * - Daemon (waitForPostgres: true) retries Postgres forever with capped\n * backoff until it comes up (handles the boot race where launchd starts\n * the daemon before Docker Desktop / Postgres is ready).\n * - CLI / one-shot callers (default) retry a few times, then throw a clear\n * error rather than returning a wrong/empty SQLite database.\n */\n\nimport type { PaiDaemonConfig } from \"../daemon/config.js\";\nimport type { StorageBackend } from \"./interface.js\";\nimport type { RegistryBackend } from \"./registry-interface.js\";\nimport { setBackendOutage, clearBackendOutage } from \"./outage.js\";\n\nexport interface StorageBackendOptions {\n /**\n * When true, retry Postgres indefinitely instead of giving up. Used by the\n * long-lived daemon so a not-yet-ready Postgres at boot is tolerated.\n * Defaults to false (one-shot CLI behaviour: bounded retries, then throw).\n */\n waitForPostgres?: boolean;\n}\n\n/** Backoff schedule (ms) for the bounded CLI retry path. */\nconst CLI_RETRY_DELAYS_MS = [500, 1_000, 2_000];\n\n/** Backoff cap (ms) for the daemon's infinite retry path. */\nconst DAEMON_RETRY_CAP_MS = 15_000;\n\n/**\n * Create and return the configured StorageBackend.\n *\n * Auto-behaviour:\n * - storageBackend = \"sqlite\" → SQLiteBackend always\n * - storageBackend = \"postgres\" → PostgresBackend (retried; never falls back)\n */\nexport async function createStorageBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<StorageBackend> {\n if (config.storageBackend === \"postgres\") {\n return await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);\n }\n\n // Default: SQLite\n return createSQLiteBackend();\n}\n\n/**\n * Attempt a single Postgres connection (ensure DB + test). Returns the live\n * backend on success, or an error string describing why it failed.\n */\nasync function attemptPostgres(\n config: PaiDaemonConfig\n): Promise<{ backend: StorageBackend } | { error: string }> {\n const { PostgresBackend } = await import(\"./postgres.js\");\n const pgConfig = config.postgres ?? {};\n\n let backend: InstanceType<typeof PostgresBackend> | null = null;\n try {\n // Ensure the per-user database exists and has the schema applied.\n await PostgresBackend.ensureDatabase(pgConfig);\n\n backend = new PostgresBackend(pgConfig);\n const err = await backend.testConnection();\n if (err) {\n await backend.close().catch(() => {});\n return { error: err };\n }\n return { backend };\n } catch (e) {\n if (backend) await backend.close().catch(() => {});\n return { error: e instanceof Error ? e.message : String(e) };\n }\n}\n\n/**\n * Consecutive failures before the outage is escalated to the user.\n *\n * Not the first failure: a container restarting, or the daemon starting before\n * Docker is up, recovers within a few seconds and is not worth a notification.\n * By the fifth attempt the backoff has already spent tens of seconds, which is\n * long enough that something is actually wrong.\n */\nconst ESCALATE_AFTER_ATTEMPTS = 5;\n\n/** Tell the user the backend is down, through whatever channels are configured. */\nasync function notifyBackendDown(attempts: number, lastError: string): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n await routeNotification(\n {\n event: \"error\",\n title: \"PAI: storage backend unreachable\",\n message:\n `Postgres has not answered in ${attempts} attempts (${lastError}). ` +\n `Indexing, session notes and the work queue are stalled until it returns. ` +\n `Check the container, then \\`pai daemon status\\`.`,\n },\n loadConfig().notifications\n );\n } catch {\n // A notification that cannot be sent must never take the daemon down with\n // it — the daemon retrying is still the useful behaviour here.\n }\n}\n\n/** And say when it comes back, so the alert is not left hanging. */\nasync function notifyBackendRecovered(\n attempts: number,\n since: number | null\n): Promise<void> {\n try {\n const { routeNotification } = await import(\"../notifications/router.js\");\n const { loadConfig } = await import(\"../daemon/config.js\");\n const mins = since ? Math.max(1, Math.round((Date.now() - since) / 60_000)) : null;\n await routeNotification(\n {\n event: \"completion\",\n title: \"PAI: storage backend back\",\n message:\n `Postgres answered after ${attempts} attempts` +\n (mins ? `, ${mins} min down` : \"\") +\n `. The queue will drain on its own.`,\n },\n loadConfig().notifications\n );\n } catch {\n /* same reasoning as above */\n }\n}\n\n/**\n * Shared retry loop: attempt a connection, back off, escalate, repeat — used\n * by both the federation and registry Postgres connectors so the retry/outage\n * behaviour (§6, \"never falls back to SQLite silently\") stays in one place.\n */\nasync function retryConnect<T>(\n attempt: () => Promise<{ backend: T } | { error: string }>,\n waitForever: boolean,\n label: string\n): Promise<T> {\n let attemptN = 0;\n let outageSince: number | null = null;\n let escalated = false;\n\n // eslint-disable-next-line no-constant-condition\n while (true) {\n attemptN++;\n const result = await attempt();\n if (\"backend\" in result) {\n if (attemptN > 1) process.stderr.write(`[pai-daemon] Connected to ${label} (after ${attemptN} attempts).\\n`);\n // An outage that ended must stop being reported, or the status command\n // trades one wrong answer for another.\n clearBackendOutage();\n if (escalated) void notifyBackendRecovered(attemptN, outageSince);\n return result.backend;\n }\n\n const lastError = result.error;\n\n if (!waitForever && attemptN > CLI_RETRY_DELAYS_MS.length) {\n // Bounded CLI path exhausted — fail loudly, never silently use SQLite.\n throw new Error(\n `${label} unreachable after ${attemptN} attempts: ${lastError}. ` +\n `Is Docker Desktop / Postgres running? Refusing to fall back to SQLite ` +\n `(would split the corpus). Start Postgres and retry.`\n );\n }\n\n const delayMs = waitForever\n ? Math.min(DAEMON_RETRY_CAP_MS, 1_000 * 2 ** Math.min(attemptN - 1, 4))\n : CLI_RETRY_DELAYS_MS[attemptN - 1];\n\n process.stderr.write(\n `[pai-daemon] ${label} unavailable (${lastError}). ` +\n `Retry ${attemptN}${waitForever ? \"\" : `/${CLI_RETRY_DELAYS_MS.length + 1}`} ` +\n `in ${delayMs}ms...\\n`\n );\n\n // Publish the outage so `pai daemon status` can report it. Without this the\n // daemon retries silently forever and status still reads \"idle\" — which is\n // what happened for two days in July: 144 retries over 36 minutes, session\n // notes never written, and the one command anyone would run to check\n // reporting that everything was fine.\n setBackendOutage({\n backend: label,\n since: outageSince ?? (outageSince = Date.now()),\n attempts: attemptN,\n lastError: String(lastError),\n });\n\n // And escalate once, out loud, rather than only into a log nobody tails.\n // Once — not per retry — because a notification that repeats every few\n // seconds is filtered within a minute and stops being a signal at all.\n if (waitForever && attemptN === ESCALATE_AFTER_ATTEMPTS && !escalated) {\n escalated = true;\n void notifyBackendDown(attemptN, String(lastError));\n }\n\n await new Promise((r) => setTimeout(r, delayMs));\n }\n}\n\nasync function connectPostgres(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n return retryConnect(() => attemptPostgres(config), waitForever, \"PostgreSQL backend\");\n}\n\n/**\n * The process's one Postgres connection: storage and registry share a\n * single pg Pool (one connection budget) instead of each opening their own.\n * Whichever of createStorageBackend()/createRegistryBackend() runs first\n * creates it via connectPostgres()'s retry/outage handling; the other\n * reuses its pool through PostgresBackend.getPool(). Reset by closeStorage().\n */\nlet pgBackendPromise: Promise<StorageBackend> | null = null;\n\nfunction getSharedPostgresBackend(\n config: PaiDaemonConfig,\n waitForever: boolean\n): Promise<StorageBackend> {\n if (!pgBackendPromise) {\n pgBackendPromise = connectPostgres(config, waitForever);\n }\n return pgBackendPromise;\n}\n\nasync function createSQLiteBackend(): Promise<StorageBackend> {\n const { openFederation } = await import(\"./sqlite/federation-db.js\");\n const { SQLiteBackend } = await import(\"./sqlite.js\");\n const db = openFederation();\n return new SQLiteBackend(db);\n}\n\n/**\n * Create and return the configured RegistryBackend (projects, sessions,\n * tags, aliases, links, compaction_log). Same auto-behaviour and same\n * never-fall-back-to-SQLite rule as createStorageBackend().\n */\nexport async function createRegistryBackend(\n config: PaiDaemonConfig,\n opts: StorageBackendOptions = {}\n): Promise<RegistryBackend> {\n if (config.storageBackend === \"postgres\") {\n // Reuses the storage backend's pool (getSharedPostgresBackend) rather\n // than opening a second one against the same database.\n const storage = await getSharedPostgresBackend(config, opts.waitForPostgres ?? false);\n const { PostgresRegistryBackend } = await import(\"./registry-postgres.js\");\n const pool = (storage as unknown as { getPool: () => import(\"pg\").Pool }).getPool();\n return new PostgresRegistryBackend(pool);\n }\n return createSQLiteRegistryBackend();\n}\n\nasync function createSQLiteRegistryBackend(): Promise<RegistryBackend> {\n const { openRegistry } = await import(\"./sqlite/registry-db.js\");\n const { SQLiteRegistryBackend } = await import(\"./registry-sqlite.js\");\n const db = openRegistry();\n return new SQLiteRegistryBackend(db);\n}\n\n/**\n * Process-wide backend accessors.\n *\n * Callers across the codebase must get their StorageBackend/RegistryBackend\n * from here rather than constructing SQLite or Postgres directly — this is\n * the one place that decides which backend a process uses, and the one place\n * that closes it on exit.\n */\n\nlet storagePromise: Promise<StorageBackend> | null = null;\nlet registryPromise: Promise<RegistryBackend> | null = null;\nlet beforeExitRegistered = false;\n\nfunction registerBeforeExit(): void {\n if (beforeExitRegistered) return;\n beforeExitRegistered = true;\n process.once(\"beforeExit\", () => {\n void closeStorage();\n });\n}\n\n/**\n * Returns the process-wide StorageBackend, creating it on first call.\n *\n * The whole body up to and including the `storagePromise = (async () =>\n * ...)()` assignment must run synchronously (no `await` before it) — two\n * calls issued in the same tick (e.g. `Promise.all([get(), get()])`) must\n * both observe the same cached promise rather than each racing to create\n * their own backend.\n */\nexport async function getStorageBackend(): Promise<StorageBackend> {\n if (!storagePromise) {\n storagePromise = (async () => {\n const { loadConfig } = await import(\"../daemon/config.js\");\n registerBeforeExit();\n return await createStorageBackend(loadConfig());\n })();\n }\n return storagePromise;\n}\n\n/** Returns the process-wide RegistryBackend, creating it on first call. Same synchronous-caching requirement as getStorageBackend(). */\nexport async function getRegistryBackend(): Promise<RegistryBackend> {\n if (!registryPromise) {\n registryPromise = (async () => {\n const { loadConfig } = await import(\"../daemon/config.js\");\n registerBeforeExit();\n return await createRegistryBackend(loadConfig());\n })();\n }\n return registryPromise;\n}\n\n/** Closes any backends created via getStorageBackend()/getRegistryBackend(). Idempotent. */\nexport async function closeStorage(): Promise<void> {\n const pending = [storagePromise, registryPromise];\n const pg = pgBackendPromise;\n storagePromise = null;\n registryPromise = null;\n pgBackendPromise = null;\n for (const p of pending) {\n if (!p) continue;\n try {\n const backend = await p;\n // Postgres backends share one pool owned by pgBackendPromise (closed\n // below) — closing them here too would end() it twice.\n if (backend.backendType === \"postgres\") continue;\n await backend.close();\n } catch {\n // A backend that never connected has nothing to close.\n }\n }\n if (pg) {\n try {\n await (await pg).close();\n } catch {\n // Never connected — nothing to close.\n }\n }\n}\n\n/** Test-only: clear the process-wide cache without closing (tests own their own lifecycle). */\nexport function __resetStorageForTests(): void {\n storagePromise = null;\n registryPromise = null;\n pgBackendPromise = null;\n}\n"],"mappings":"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"}
@@ -1,7 +1,7 @@
1
- import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, l as paiConfigFilePath, m as migrateMainConfigToYaml, n as CONFIG_FILE, r as DEFAULTS, s as loadConfig, u as paiConfigYamlFilePath, v as readJsonStrict, y as writeJsonAtomic } from "./config-CQCmH_gV.mjs";
2
- import { A as writeWorkersSection, C as nativeAnthropicProvider, D as resolveCapability, E as readWorkersSection, O as resolveModelCapability, R as workersYamlPath, S as maskKey, a as DEFAULT_CACHE_KEEPALIVE_SECS, b as isModelCapability, c as DEFAULT_ROUTING, f as PROVIDER_TAGS, k as resolveProviderKey, l as DEFAULT_TREE, m as WorkersConfigError, o as DEFAULT_LOG_DIR, r as ANTHROPIC_NATIVE, s as DEFAULT_PANE, u as MODEL_CAPABILITIES, v as expandHome, w as providerCostTier, x as keysDir, y as getProviderOrNative } from "./run-env-BE5xyCwy.mjs";
3
- import { B as loadStatuses, F as contextPercent, I as elapsedOf, L as isChatPane, M as ageOf, N as alive, P as contextLabel, R as isLive, S as workerInScope, Y as shortText, a as parseWorkerReport, c as readInbox, g as currentTabKey, h as markControlsHeld, j as UNLABELED, k as appendLedger, l as sayToWorker, m as clickrControlsArgv, o as renderReport, u as workerDepth, x as sessionTag, y as resolveSession } from "./run-CZVCCvXE.mjs";
4
- import { a as clearCooldown, c as probeQuota, d as resolveTarget, h as ledgerPath, l as quotaSkipThreshold, m as eventsPath, v as workersLogDir } from "./server-BY6AedmC.mjs";
1
+ import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, l as paiConfigFilePath, m as migrateMainConfigToYaml, n as CONFIG_FILE, r as DEFAULTS, s as loadConfig, u as paiConfigYamlFilePath, v as readJsonStrict, y as writeJsonAtomic } from "./config-2wkz744W.mjs";
2
+ import { A as writeWorkersSection, C as nativeAnthropicProvider, D as resolveCapability, E as readWorkersSection, O as resolveModelCapability, R as workersYamlPath, S as maskKey, a as DEFAULT_CACHE_KEEPALIVE_SECS, b as isModelCapability, c as DEFAULT_ROUTING, f as PROVIDER_TAGS, k as resolveProviderKey, l as DEFAULT_TREE, m as WorkersConfigError, o as DEFAULT_LOG_DIR, r as ANTHROPIC_NATIVE, s as DEFAULT_PANE, u as MODEL_CAPABILITIES, v as expandHome, w as providerCostTier, x as keysDir, y as getProviderOrNative } from "./run-env-Br4Bc7Vz.mjs";
3
+ import { B as loadStatuses, F as contextPercent, I as elapsedOf, L as isChatPane, M as ageOf, N as alive, P as contextLabel, R as isLive, S as workerInScope, Y as shortText, a as parseWorkerReport, c as readInbox, g as currentTabKey, h as markControlsHeld, j as UNLABELED, k as appendLedger, l as sayToWorker, m as clickrControlsArgv, o as renderReport, u as workerDepth, x as sessionTag, y as resolveSession } from "./run-W2rV_9j0.mjs";
4
+ import { a as clearCooldown, c as probeQuota, d as resolveTarget, h as ledgerPath, l as quotaSkipThreshold, m as eventsPath, v as workersLogDir } from "./server-B71rem4q.mjs";
5
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, join, relative, resolve } from "node:path";
@@ -2204,4 +2204,4 @@ function fallbackStatusText(s) {
2204
2204
 
2205
2205
  //#endregion
2206
2206
  export { getConfigValueOp as A, psOutput as C, resolveSpecPath as D, readSpecPrompt as E, setConfigValueOp as M, unsetConfigValueOp as N, MainConfigOpsError as O, followWorkers as S, statusLineOutput as T, setWorkersEnabled as _, addProvider as a, updateProvider as b, describeModels as c, removeProvider as d, resolveProviderName as f, setProviderModel as g, setProviderEnabled as h, fallbackStatusText as i, listConfigOp as j, formatConfigGetOutput as k, describeProviders as l, setClass as m, fallbackOn as n, classTargetText as o, setCapabilityPreference as p, fallbackStatus as r, describeCapabilities as s, fallbackOff as t, modelPrefsText as u, unsetCapabilityPreference as v, replayOutput as w, useProvider as x, unsetClass as y };
2207
- //# sourceMappingURL=fallback-ktbR0aR7.mjs.map
2207
+ //# sourceMappingURL=fallback-ECiqCuh6.mjs.map