@tekmidian/pai 0.54.1 → 0.55.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 (251) hide show
  1. package/dist/{aibroker-client-CNfIBrl5.mjs → aibroker-client-B8Wtasez.mjs} +1 -1
  2. package/dist/{aibroker-client-DzkTpVjx.mjs → aibroker-client-BK1h--W_.mjs} +3 -3
  3. package/dist/{aibroker-client-DzkTpVjx.mjs.map → aibroker-client-BK1h--W_.mjs.map} +1 -1
  4. package/dist/{indexer-backend-CdvCJCkD.mjs → async-CLvuY7b-.mjs} +7 -7
  5. package/dist/async-CLvuY7b-.mjs.map +1 -0
  6. package/dist/auto-route-CF8WNaoy.mjs +100 -0
  7. package/dist/auto-route-CF8WNaoy.mjs.map +1 -0
  8. package/dist/{chain-CZKlI0hq.mjs → chain-DogidBji.mjs} +4 -4
  9. package/dist/{chain-CZKlI0hq.mjs.map → chain-DogidBji.mjs.map} +1 -1
  10. package/dist/cli/index.mjs +22 -23
  11. package/dist/cli/index.mjs.map +1 -1
  12. package/dist/cli/program.d.mts.map +1 -1
  13. package/dist/cli/program.mjs +22 -23
  14. package/dist/{clusters-Cw_P6lO-.mjs → clusters-Ci1mseh2.mjs} +4 -36
  15. package/dist/clusters-Ci1mseh2.mjs.map +1 -0
  16. package/dist/{config-B3EPzRjf.mjs → config-B93YS8tP.mjs} +3 -3
  17. package/dist/{config-B3EPzRjf.mjs.map → config-B93YS8tP.mjs.map} +1 -1
  18. package/dist/{config-Bn9jpblO.mjs → config-D1omG_UU.mjs} +3 -3
  19. package/dist/{config-Bn9jpblO.mjs.map → config-D1omG_UU.mjs.map} +1 -1
  20. package/dist/{config-RjeVGJ7K.mjs → config-DCYWFaEi.mjs} +1 -1
  21. package/dist/{context-handover-cache-CXyH6QsC.mjs → context-handover-cache-k2kWbE6G.mjs} +82 -99
  22. package/dist/context-handover-cache-k2kWbE6G.mjs.map +1 -0
  23. package/dist/daemon/index.mjs +18 -24
  24. package/dist/daemon/index.mjs.map +1 -1
  25. package/dist/daemon-B7Y8GQnt.mjs +19 -0
  26. package/dist/{daemon-aJTxzg20.mjs → daemon-Cgrdvdo6.mjs} +97 -466
  27. package/dist/daemon-Cgrdvdo6.mjs.map +1 -0
  28. package/dist/daemon-mcp/index.mjs +10 -10
  29. package/dist/daemon-mcp/index.mjs.map +1 -1
  30. package/dist/{detector-DWXkeTlk.mjs → detector-3g7XuU1r.mjs} +5 -12
  31. package/dist/detector-3g7XuU1r.mjs.map +1 -0
  32. package/dist/detector-DgDn56j-.mjs +3 -0
  33. package/dist/{embeddings-ClqKtLZ8.mjs → embeddings-BNv3TQC5.mjs} +1 -1
  34. package/dist/{embeddings-DvRKcQyt.mjs → embeddings-DfrYNIng.mjs} +1 -1
  35. package/dist/{embeddings-DvRKcQyt.mjs.map → embeddings-DfrYNIng.mjs.map} +1 -1
  36. package/dist/factory-C9wGUe30.mjs +3 -0
  37. package/dist/factory-DiuntvwG.mjs +250 -0
  38. package/dist/factory-DiuntvwG.mjs.map +1 -0
  39. package/dist/{fallback-CCFIpPha.mjs → fallback-DS6AEJsU.mjs} +190 -132
  40. package/dist/fallback-DS6AEJsU.mjs.map +1 -0
  41. package/dist/federation-db-CJDw_Pwq.mjs +3 -0
  42. package/dist/{db-DJ8rk0VM.mjs → federation-db-w8e-sKjp.mjs} +5 -5
  43. package/dist/federation-db-w8e-sKjp.mjs.map +1 -0
  44. package/dist/{helpers-crDEr6S2.mjs → helpers-D9WkIf9R.mjs} +1 -1
  45. package/dist/{helpers-crDEr6S2.mjs.map → helpers-D9WkIf9R.mjs.map} +1 -1
  46. package/dist/hooks/capture-all-events.mjs.map +1 -1
  47. package/dist/hooks/capture-session-summary.mjs.map +1 -1
  48. package/dist/hooks/capture-tool-output.mjs.map +1 -1
  49. package/dist/hooks/context-compression-hook.mjs.map +1 -1
  50. package/dist/hooks/load-core-context.mjs +5 -0
  51. package/dist/hooks/load-core-context.mjs.map +2 -2
  52. package/dist/hooks/load-project-context.mjs.map +1 -1
  53. package/dist/hooks/post-compact-inject.mjs.map +1 -1
  54. package/dist/hooks/route-agents-to-worker.mjs.map +1 -1
  55. package/dist/hooks/route-edits-to-worker.mjs +1 -1
  56. package/dist/hooks/route-edits-to-worker.mjs.map +2 -2
  57. package/dist/hooks/security-validator.mjs +8367 -19
  58. package/dist/hooks/security-validator.mjs.map +4 -4
  59. package/dist/hooks/stop-hook.mjs.map +2 -2
  60. package/dist/hooks/whisper-rules.mjs.map +2 -2
  61. package/dist/hooks/worker-proxy.mjs.map +1 -1
  62. package/dist/hooks/worker-status-line.mjs.map +2 -2
  63. package/dist/hooks/worker-supervision.mjs.map +1 -1
  64. package/dist/index.d.mts +327 -100
  65. package/dist/index.d.mts.map +1 -1
  66. package/dist/index.mjs +7 -11
  67. package/dist/indexer-backend-Cc-_NK3q.mjs +1 -0
  68. package/dist/indexer-backend-DcDtin1O.mjs +5 -0
  69. package/dist/{ipc-client-C6NryMMb.mjs → ipc-client-Dasm515c.mjs} +2 -2
  70. package/dist/{ipc-client-C6NryMMb.mjs.map → ipc-client-Dasm515c.mjs.map} +1 -1
  71. package/dist/kg-entity-Cj8Q6YmU.mjs +29 -0
  72. package/dist/kg-entity-Cj8Q6YmU.mjs.map +1 -0
  73. package/dist/{latent-ideas-BfL4nCQ3.mjs → latent-ideas-BRy6HNwg.mjs} +4 -4
  74. package/dist/{latent-ideas-BfL4nCQ3.mjs.map → latent-ideas-BRy6HNwg.mjs.map} +1 -1
  75. package/dist/{link-boost-C5PaNdzm.mjs → link-boost-BptbAPuv.mjs} +1 -1
  76. package/dist/{link-boost-C5PaNdzm.mjs.map → link-boost-BptbAPuv.mjs.map} +1 -1
  77. package/dist/{main-resolver-Bt1f6jJc.mjs → main-resolver-Dx-pxic1.mjs} +56 -31
  78. package/dist/main-resolver-Dx-pxic1.mjs.map +1 -0
  79. package/dist/main-resolver-uDtK9p6e.mjs +7 -0
  80. package/dist/merge-BncjJTfb.mjs +3 -0
  81. package/dist/merge-xoyfZQ4W.mjs +6 -0
  82. package/dist/merge-xoyfZQ4W.mjs.map +1 -0
  83. package/dist/{migrate-CfERwXhY.mjs → migrate-BD7D8EEh.mjs} +21 -54
  84. package/dist/migrate-BD7D8EEh.mjs.map +1 -0
  85. package/dist/{neighborhood-CETR_P4y.mjs → neighborhood-D8uscnGy.mjs} +4 -25
  86. package/dist/neighborhood-D8uscnGy.mjs.map +1 -0
  87. package/dist/{note-context-DCcRWBoN.mjs → note-context-CgffaUMh.mjs} +3 -23
  88. package/dist/note-context-CgffaUMh.mjs.map +1 -0
  89. package/dist/{pai-home-ChAOupZc.mjs → pai-home-CeOIYltV.mjs} +2 -2
  90. package/dist/pai-home-CeOIYltV.mjs.map +1 -0
  91. package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-D1MMswkz.mjs} +1 -1
  92. package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-D1MMswkz.mjs.map} +1 -1
  93. package/dist/{planner-D3keWys5.mjs → planner-C2wWnZrZ.mjs} +7 -7
  94. package/dist/{planner-D3keWys5.mjs.map → planner-C2wWnZrZ.mjs.map} +1 -1
  95. package/dist/{postgres-CUSXWgTG.mjs → postgres-nvU5pShF.mjs} +863 -4
  96. package/dist/postgres-nvU5pShF.mjs.map +1 -0
  97. package/dist/{program-B3eRMhff.mjs → program-Cb-9J5Ie.mjs} +2260 -1399
  98. package/dist/program-Cb-9J5Ie.mjs.map +1 -0
  99. package/dist/query-feedback-COYCnvHQ.mjs +3 -0
  100. package/dist/{query-feedback-CTs_jx6i.mjs → query-feedback-xz5axTj3.mjs} +3 -3
  101. package/dist/query-feedback-xz5axTj3.mjs.map +1 -0
  102. package/dist/{db-Ddns80yf.mjs → registry-db-Bkpbp7Gf.mjs} +5 -5
  103. package/dist/registry-db-Bkpbp7Gf.mjs.map +1 -0
  104. package/dist/registry-db-DwXpf7yF.mjs +3 -0
  105. package/dist/registry-postgres-B07-hmn8.mjs +795 -0
  106. package/dist/registry-postgres-B07-hmn8.mjs.map +1 -0
  107. package/dist/registry-sqlite-CkGND_tm.mjs +593 -0
  108. package/dist/registry-sqlite-CkGND_tm.mjs.map +1 -0
  109. package/dist/{reranker-B5qUTamW.mjs → reranker-BS5gqGeF.mjs} +1 -1
  110. package/dist/{reranker-HHmhZk6v.mjs → reranker-CRVawoEh.mjs} +1 -1
  111. package/dist/{reranker-HHmhZk6v.mjs.map → reranker-CRVawoEh.mjs.map} +1 -1
  112. package/dist/router-BXOf_e9e.mjs +3 -0
  113. package/dist/{router-DBAZfE7X.mjs → router-BdxdsAvs.mjs} +2 -2
  114. package/dist/{router-DBAZfE7X.mjs.map → router-BdxdsAvs.mjs.map} +1 -1
  115. package/dist/{run-DLG25bzt.mjs → run-DkTrZejF.mjs} +234 -49
  116. package/dist/run-DkTrZejF.mjs.map +1 -0
  117. package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-BRwDeBEg.mjs} +1 -1
  118. package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-BRwDeBEg.mjs.map} +1 -1
  119. package/dist/search-BQHL5wDr.mjs +3 -0
  120. package/dist/search-BaTbyWjK.mjs +107 -0
  121. package/dist/search-BaTbyWjK.mjs.map +1 -0
  122. package/dist/{server-CGiHdaUp.mjs → server-CGuZFOeR.mjs} +4 -4
  123. package/dist/{server-CGiHdaUp.mjs.map → server-CGuZFOeR.mjs.map} +1 -1
  124. package/dist/{session-keepalive-DklS8VmU.mjs → session-keepalive-C5_69Av6.mjs} +7 -7
  125. package/dist/{session-keepalive-DklS8VmU.mjs.map → session-keepalive-C5_69Av6.mjs.map} +1 -1
  126. package/dist/skills/Plan/SKILL.md +1 -1
  127. package/dist/{sources-KBGEA_E6.mjs → sources-BBe3AZ-W.mjs} +8 -27
  128. package/dist/sources-BBe3AZ-W.mjs.map +1 -0
  129. package/dist/sqlite-D9p5a4qe.mjs +935 -0
  130. package/dist/sqlite-D9p5a4qe.mjs.map +1 -0
  131. package/dist/state-CwlGAKgF.mjs +3 -0
  132. package/dist/{state-DwrsOoMi.mjs → state-n13uPK5B.mjs} +6 -5
  133. package/dist/state-n13uPK5B.mjs.map +1 -0
  134. package/dist/{stop-words-BaMEGVeY.mjs → stop-words-ClCwO8mZ.mjs} +1 -1
  135. package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-ClCwO8mZ.mjs.map} +1 -1
  136. package/dist/{themes-BVoAnLZj.mjs → themes-B2v7RNnA.mjs} +3 -3
  137. package/dist/{themes-BVoAnLZj.mjs.map → themes-B2v7RNnA.mjs.map} +1 -1
  138. package/dist/{tools-BEqiJt_j.mjs → tools-Diro2s8J.mjs} +157 -398
  139. package/dist/tools-Diro2s8J.mjs.map +1 -0
  140. package/dist/{tools-BwsOTmjh.mjs → tools-KgL1rkPD.mjs} +4 -4
  141. package/dist/{trace-Cy8quQTm.mjs → trace-BRflVyBi.mjs} +1 -1
  142. package/dist/{trace-Cy8quQTm.mjs.map → trace-BRflVyBi.mjs.map} +1 -1
  143. package/dist/{utils-BD8g8oSf.mjs → utils-BrX9FLeK.mjs} +1 -1
  144. package/dist/{utils-CJymHkYm.mjs → utils-C9HsYDpO.mjs} +1 -1
  145. package/dist/{utils-CJymHkYm.mjs.map → utils-C9HsYDpO.mjs.map} +1 -1
  146. package/dist/{vault-indexer-C5i-OAyr.mjs → vault-indexer-DHYBpRZ5.mjs} +2 -2
  147. package/dist/{vault-indexer-C5i-OAyr.mjs.map → vault-indexer-DHYBpRZ5.mjs.map} +1 -1
  148. package/dist/{wakeup-Yk5lQ0jb.mjs → wakeup-D0uvqLsE.mjs} +95 -4
  149. package/dist/wakeup-D0uvqLsE.mjs.map +1 -0
  150. package/dist/work-queue-worker-DEbWsrDQ.mjs +11 -0
  151. package/dist/{work-queue-worker-utPp4hUv.mjs → work-queue-worker-DufMOtrL.mjs} +9 -9
  152. package/dist/work-queue-worker-DufMOtrL.mjs.map +1 -0
  153. package/dist/{zettelkasten-DezbXghl.mjs → zettelkasten-BPxSOalU.mjs} +4 -4
  154. package/dist/{zettelkasten-DezbXghl.mjs.map → zettelkasten-BPxSOalU.mjs.map} +1 -1
  155. package/docker/init.sql +5 -1
  156. package/docs/commands/README.md +5 -0
  157. package/docs/commands/audit.md +1 -1
  158. package/docs/commands/backup.md +1 -1
  159. package/docs/commands/clear-names.md +1 -1
  160. package/docs/commands/config.md +4 -4
  161. package/docs/commands/daemon.md +1 -1
  162. package/docs/commands/db.md +15 -1
  163. package/docs/commands/end.md +1 -1
  164. package/docs/commands/help.md +1 -1
  165. package/docs/commands/hooks-db.md +47 -0
  166. package/docs/commands/identity.md +1 -1
  167. package/docs/commands/kg.md +1 -1
  168. package/docs/commands/launch.md +1 -1
  169. package/docs/commands/mcp.md +1 -1
  170. package/docs/commands/memory.md +1 -1
  171. package/docs/commands/notify.md +1 -1
  172. package/docs/commands/observation.md +1 -1
  173. package/docs/commands/obsidian.md +1 -1
  174. package/docs/commands/pause.md +1 -1
  175. package/docs/commands/project.md +1 -1
  176. package/docs/commands/projects.md +1 -1
  177. package/docs/commands/registry.md +1 -1
  178. package/docs/commands/restore.md +1 -1
  179. package/docs/commands/session.md +2 -2
  180. package/docs/commands/sessions.md +1 -1
  181. package/docs/commands/setup.md +1 -1
  182. package/docs/commands/shell-init.md +1 -1
  183. package/docs/commands/skill.md +1 -1
  184. package/docs/commands/task.md +1 -1
  185. package/docs/commands/topic.md +1 -1
  186. package/docs/commands/update.md +1 -1
  187. package/docs/commands/worker.md +1 -1
  188. package/docs/commands/zettel.md +1 -1
  189. package/docs/design/postgres-only.md +438 -0
  190. package/docs/design/registry-method-map.md +174 -0
  191. package/package.json +1 -1
  192. package/src/hooks/pre-compact.sh +2 -40
  193. package/src/hooks/session-hooks-db.test.ts +93 -0
  194. package/src/hooks/session-stop.sh +2 -26
  195. package/src/hooks/ts/lib/edit-gate.test.ts +9 -0
  196. package/src/hooks/ts/lib/edit-gate.ts +5 -2
  197. package/src/hooks/ts/lib/plutil-guard.test.ts +54 -0
  198. package/src/hooks/ts/lib/plutil-guard.ts +43 -0
  199. package/src/hooks/ts/lib/worker-git-guard.test.ts +134 -0
  200. package/src/hooks/ts/lib/worker-git-guard.ts +103 -0
  201. package/src/hooks/ts/pre-tool-use/security-validator.test.ts +68 -0
  202. package/src/hooks/ts/pre-tool-use/security-validator.ts +69 -21
  203. package/src/hooks/ts/session-start/load-core-context.ts +13 -0
  204. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +13 -0
  205. package/dist/auto-route-CcD0OWWB.mjs +0 -86
  206. package/dist/auto-route-CcD0OWWB.mjs.map +0 -1
  207. package/dist/clusters-Cw_P6lO-.mjs.map +0 -1
  208. package/dist/context-handover-cache-CXyH6QsC.mjs.map +0 -1
  209. package/dist/daemon-CknQ6YGZ.mjs +0 -25
  210. package/dist/daemon-aJTxzg20.mjs.map +0 -1
  211. package/dist/db-BOBfewFH.mjs +0 -3
  212. package/dist/db-DJ8rk0VM.mjs.map +0 -1
  213. package/dist/db-DbY2O8QO.mjs +0 -3
  214. package/dist/db-Ddns80yf.mjs.map +0 -1
  215. package/dist/detect-Bf2z-oKB.mjs +0 -86
  216. package/dist/detect-Bf2z-oKB.mjs.map +0 -1
  217. package/dist/detector-CkwkXuGh.mjs +0 -5
  218. package/dist/detector-DWXkeTlk.mjs.map +0 -1
  219. package/dist/factory-Ba8j9K7q.mjs +0 -3
  220. package/dist/factory-Bt7TWQje.mjs +0 -150
  221. package/dist/factory-Bt7TWQje.mjs.map +0 -1
  222. package/dist/fallback-CCFIpPha.mjs.map +0 -1
  223. package/dist/indexer-backend-CdvCJCkD.mjs.map +0 -1
  224. package/dist/kg-entity-BSW7ra8i.mjs +0 -176
  225. package/dist/kg-entity-BSW7ra8i.mjs.map +0 -1
  226. package/dist/main-resolver-Bt1f6jJc.mjs.map +0 -1
  227. package/dist/main-resolver-DYHhw5Ig.mjs +0 -7
  228. package/dist/migrate-CfERwXhY.mjs.map +0 -1
  229. package/dist/neighborhood-CETR_P4y.mjs.map +0 -1
  230. package/dist/note-context-DCcRWBoN.mjs.map +0 -1
  231. package/dist/pai-home-ChAOupZc.mjs.map +0 -1
  232. package/dist/postgres-CUSXWgTG.mjs.map +0 -1
  233. package/dist/program-B3eRMhff.mjs.map +0 -1
  234. package/dist/query-feedback-Bm6XvCmg.mjs +0 -3
  235. package/dist/query-feedback-CTs_jx6i.mjs.map +0 -1
  236. package/dist/router-CremHKTI.mjs +0 -3
  237. package/dist/run-DLG25bzt.mjs.map +0 -1
  238. package/dist/search-B69cQwDB.mjs +0 -303
  239. package/dist/search-B69cQwDB.mjs.map +0 -1
  240. package/dist/search-fb-5ovj4.mjs +0 -4
  241. package/dist/sources-KBGEA_E6.mjs.map +0 -1
  242. package/dist/sqlite-By6r2YhZ.mjs +0 -271
  243. package/dist/sqlite-By6r2YhZ.mjs.map +0 -1
  244. package/dist/state-Ca0ltLgI.mjs +0 -3
  245. package/dist/state-DwrsOoMi.mjs.map +0 -1
  246. package/dist/sync-Btcb6xA4.mjs +0 -310
  247. package/dist/sync-Btcb6xA4.mjs.map +0 -1
  248. package/dist/tools-BEqiJt_j.mjs.map +0 -1
  249. package/dist/wakeup-Yk5lQ0jb.mjs.map +0 -1
  250. package/dist/work-queue-worker-DsdJx-jn.mjs +0 -14
  251. package/dist/work-queue-worker-utPp4hUv.mjs.map +0 -1
@@ -0,0 +1,438 @@
1
+ # Postgres-only storage — design
2
+
3
+ Goal: when `storageBackend: postgres`, no code path opens or writes SQLite. Everything that
4
+ today lives only in `federation.db` (`kg_entities`) or `registry.db` (projects, sessions,
5
+ aliases, tags, session_tags, links, compaction_log) moves to Postgres, verified, with the
6
+ SQLite files kept as renamed backups — never deleted. Measured state (2026-09-22) and the
7
+ prior `StorageBackend` abstraction (`src/storage/{interface,sqlite,postgres}.ts`) are taken
8
+ as given; this document extends that layer rather than replacing it.
9
+
10
+ Hard constraint (added 2026-09-22, from the container definition): Postgres runs in
11
+ `pai-pgvector` (`docker/docker-compose.yml`, `pgvector/pgvector:pg17`, `127.0.0.1:5432`)
12
+ with a **bind mount**, not a named volume — confirmed via `docker inspect`:
13
+ `~/.pai/pgdata -> /var/lib/postgresql/data`, rw, type `bind`. Every step in
14
+ this design (schema init, migration, verification) goes through the normal `pg` connection
15
+ on 5432; nothing writes into the container filesystem or a Docker-managed volume, and no
16
+ step uses `docker cp`. The migration command's pre-flight check and rollback step (§4) both
17
+ depend on this bind mount being present on the host, not on anything inside the container.
18
+
19
+ Open question, not resolved by this change: `~/.pai/pgdata` sits outside `PAI_HOME`
20
+ (`~/.claude/pai`), i.e. Postgres data isn't backed up/relocated the way the rest of PAI's
21
+ state is. Out of scope here — flagging it, not moving it.
22
+
23
+ ## 1. Inventory — every non-test file that opens or touches SQLite
24
+
25
+ ### 1a. Storage-layer definition files (own the schema/opener today)
26
+
27
+ | File | Role |
28
+ |---|---|
29
+ | `src/memory/db.ts` | defines `openFederation()` — `new BetterSqlite3(federation.db)`, sync |
30
+ | `src/memory/schema.ts` | `federation.db` DDL: `memory_files`, `memory_chunks`, `memory_fts`, **`kg_entities`** |
31
+ | `src/registry/db.ts` | defines `openRegistry()` — `new BetterSqlite3(registry.db)`, sync |
32
+ | `src/registry/schema.ts` | `registry.db` DDL: `projects`, `sessions`, `tags`, `project_tags`, `session_tags`, `aliases`, `compaction_log`, `links`, `schema_version` |
33
+
34
+ `grep -rn "new Database(" src` outside these two openers only matches two test files
35
+ (`project/here.test.ts`, `project/mcp-tools.test.ts`, both `:memory:` — fine, tests are
36
+ out of scope).
37
+
38
+ ### 1b. Direct call sites that acquire a `Database` handle (sync, better-sqlite3)
39
+
40
+ | File:line | Opens | Tables reached |
41
+ |---|---|---|
42
+ | `src/cli/program.ts:83` | `openRegistry()` → cached in module-level `getDb()`, passed to **~60** CLI command modules (§1e) | all registry tables |
43
+ | `src/daemon/daemon/server.ts:138` | `openRegistry()` → `setRegistryDb()`, shared with daemon handlers | all registry tables |
44
+ | `src/daemon/daemon/state.ts` | imports `openRegistry` (daemon state init) | all registry tables |
45
+ | `src/workers/project-config.ts:44` | `openRegistry(dbPath)` | `projects`, `sessions`, `session_config` column |
46
+ | `src/cli/commands/task.ts:125,302` | `openRegistry()` | `aliases`, `projects` |
47
+ | `src/cli/commands/session/commands.ts:594` | `openRegistry()` | `sessions`, `projects` |
48
+ | `src/memory/kg-backfill.ts:184,187` | `openFederation()` **and** `openRegistry()` in the same pass | `kg_entities`, `projects` |
49
+ | `src/daemon/daemon/dispatcher.ts:174,184` | `openFederation()` | `memory_files`, `memory_chunks`, `kg_entities` (indirectly) |
50
+ | `src/daemon/daemon/scheduler.ts:295` | `openFederation()` | passed into `indexAll()` |
51
+ | `src/daemon/session-summary-worker.ts:950` | `openFederation()` | passed into `kg-extraction.ts` |
52
+ | `src/cli/commands/db.ts:32` | `openFederation()` | `pai db` stats/inspect command |
53
+ | `src/cli/commands/zettel/utils.ts:20` | `openFederation()` | zettelkasten commands (legacy path; most zettelkasten modules already take `StorageBackend`, see §1e) |
54
+ | `src/cli/commands/memory/{stats,embed,index-cmd,search}.ts` | `openFederation()` (fallback path — the primary path in these files already goes through `createStorageBackend()`) | `memory_files`, `memory_chunks` |
55
+ | `src/storage/factory.ts:214` | `openFederation()` — **this is the SQLite branch of the existing abstraction**, expected to remain for `storageBackend: sqlite` | wraps in `SQLiteBackend` |
56
+
57
+ ### 1c. Shell hooks (sync, `sqlite3` CLI subprocess — not better-sqlite3, but still opens SQLite)
58
+
59
+ | File | Tables |
60
+ |---|---|
61
+ | `src/hooks/session-stop.sh:118,122,132` | reads/writes `projects`, `sessions` |
62
+ | `src/hooks/pre-compact.sh:47,51,60,71,75` | reads/writes `projects`, `sessions`, `compaction_log` |
63
+
64
+ These run as plain bash subprocesses with no Node runtime — they cannot use a `pg` pool
65
+ directly (see §6, risk: hooks under Postgres).
66
+
67
+ ### 1d. `kg_entities` consumers (sync, take a `Database` handle as a parameter)
68
+
69
+ | File | Functions | Sync |
70
+ |---|---|---|
71
+ | `src/memory/kg-entity.ts` | `upsertKgEntity`, `findKgEntity`, `listKgEntities`, `updateEntityFeedbackWeight` — all `db.prepare(...)` | sync |
72
+ | `src/memory/kg-extraction.ts` | calls `upsertKgEntity` | sync |
73
+ | `src/memory/kg-search.ts` | calls `listKgEntities` | sync |
74
+ | `src/mcp/tools/feedback.ts` | calls `listKgEntities`, `updateEntityFeedbackWeight` | sync |
75
+
76
+ Note: `kg_triples` (the edges) is **already Postgres-only** (`src/storage/postgres/backend.ts`
77
+ `runMigrations()` creates it directly in Postgres, no SQLite equivalent exists). Only
78
+ `kg_entities` is still SQLite-only — it is the smaller of the two gaps in the architecture.
79
+
80
+ ### 1e. `registry.db` table consumers (sync, reached via `getDb()` from `src/cli/program.ts` or an
81
+ equivalent daemon/worker singleton)
82
+
83
+ 64 non-test files contain direct `db.prepare(...)` SQL against `projects` / `sessions` /
84
+ `aliases` / `tags` / `session_tags` / `links` / `compaction_log`. Grouped by module (every
85
+ file in the grep match is listed; none omitted):
86
+
87
+ - **CLI project commands** (registry: `projects`, `aliases`, `tags`, `project_tags`):
88
+ `src/cli/commands/project/{index,commands,here,helpers,health,merge,unregister,projects-index,session-config}.ts`,
89
+ `src/cli/commands/main-resolver.ts`, `src/cli/commands/pick.ts`, `src/cli/commands/detect.ts`
90
+ - **CLI session commands** (registry: `sessions`, `session_tags`, `compaction_log`):
91
+ `src/cli/commands/session/{index,commands,autosave,pause,handover,goto,end,helpers,sessions-index}.ts`,
92
+ `src/cli/commands/session-cleanup/{index,scanner,executor}.ts`, `src/cli/lib/{session-scan,dedup-sessions}.ts`
93
+ - **CLI registry commands** (registry: all tables, dedup/scan/migrate tooling):
94
+ `src/cli/commands/registry/{index,scan,reconnect,dedupe,migrate,utils}.ts`, `src/registry/merge.ts`,
95
+ `src/registry/migrate.ts` (`migrateFromJson` writes `projects`/`sessions`)
96
+ - **CLI memory/obsidian/backup/config**: `src/cli/commands/{memory/stats,memory/search,memory/index-cmd,
97
+ memory/embed,backup,restore,config,obsidian}.ts`, `src/obsidian/sync/{generate,master,symlinks}.ts`,
98
+ `src/obsidian/status.ts`, `src/obsidian/vault-fixer.ts`
99
+ - **MCP tools** (registry reads for tool responses): `src/mcp/tools/{registry,projects,sessions,wakeup,types}.ts`
100
+ - **Daemon**: `src/daemon/daemon/{dispatcher,handler,scheduler,server,state,types}.ts`,
101
+ `src/daemon/session-summary-worker.ts` — `handler.ts` is the clearest existing example of the
102
+ target failure mode: it already mixes `registryDb.prepare(...)` (sync SQLite) and
103
+ `pool.query(...)` (async Postgres, via `PostgresBackendWithPool`) in the same file.
104
+ - **Session/task/topic routing**: `src/session/{auto-route,promote}.ts`, `src/tasks/resolver.ts`,
105
+ `src/topics/detector.ts` (note: `detector.ts` primarily takes `StorageBackend`; its one direct
106
+ registry touch is a `sessions`-adjacent lookup, confirm during implementation unit 3)
107
+ - **Workers**: `src/workers/project-config.ts`
108
+
109
+ `grep -n "getDb(" src/cli` (17 files) shows every one of the above CLI files receives the
110
+ handle from `program.ts`'s singleton rather than opening its own — they are "opens SQLite"
111
+ in the sense the boundary test (§3) must catch (they import `Database` from `better-sqlite3`
112
+ and call `.prepare()`), even though the physical `openRegistry()` call happens once, upstream.
113
+
114
+ ### Summary
115
+
116
+ | Data | Only copy today | Direct openers | Downstream consumers |
117
+ |---|---|---|---|
118
+ | `kg_entities` | `federation.db` (23,343 rows, live writes) | 1 (`kg-backfill.ts`) + factory/dispatcher opens for other reasons | 4 files (§1d) |
119
+ | `projects, sessions, aliases, tags, session_tags, links, compaction_log` | `registry.db` (278/6,892/26/5/5/3/664) | ~10 call sites (§1b) + 2 shell hooks (§1c) | 64 files (§1e) |
120
+
121
+ ## 2. Target Postgres schema
122
+
123
+ Follows the existing pattern exactly: base DDL lives in `docker/init.sql` (applied by
124
+ `PostgresBackend.ensureDatabase()` on a fresh database), incremental additions go through
125
+ `PostgresBackend.runMigrations()` in `src/storage/postgres/backend.ts` — the same place
126
+ `kg_triples` was added. `kg_entities` and the registry tables are added the same way,
127
+ **appended to `runMigrations()`** rather than `init.sql`, so existing Postgres databases
128
+ (which already have `pai_files`/`pai_chunks`/`kg_triples`/vault tables) pick them up without
129
+ a fresh `CREATE DATABASE`.
130
+
131
+ ```sql
132
+ -- appended to PostgresBackend.runMigrations(), guarded by information_schema checks
133
+ -- exactly like the existing kg_triples migration
134
+
135
+ CREATE TABLE IF NOT EXISTS kg_entities (
136
+ entity_id TEXT PRIMARY KEY,
137
+ tenant_id TEXT NOT NULL DEFAULT 'default',
138
+ name TEXT NOT NULL,
139
+ type TEXT NOT NULL DEFAULT 'unknown',
140
+ description TEXT,
141
+ first_seen BIGINT,
142
+ last_seen BIGINT,
143
+ mention_count INTEGER NOT NULL DEFAULT 1,
144
+ feedback_weight REAL NOT NULL DEFAULT 0.5,
145
+ UNIQUE (tenant_id, entity_id)
146
+ );
147
+ CREATE INDEX IF NOT EXISTS idx_kge_tenant ON kg_entities(tenant_id);
148
+ CREATE INDEX IF NOT EXISTS idx_kge_name ON kg_entities(tenant_id, name);
149
+ CREATE INDEX IF NOT EXISTS idx_kge_type ON kg_entities(tenant_id, type);
150
+
151
+ CREATE TABLE IF NOT EXISTS projects (
152
+ id SERIAL PRIMARY KEY,
153
+ slug TEXT NOT NULL UNIQUE,
154
+ display_name TEXT NOT NULL,
155
+ root_path TEXT NOT NULL UNIQUE,
156
+ encoded_dir TEXT NOT NULL UNIQUE,
157
+ type TEXT NOT NULL DEFAULT 'local'
158
+ CHECK (type IN ('local','central','obsidian-linked','external')),
159
+ status TEXT NOT NULL DEFAULT 'active'
160
+ CHECK (status IN ('active','archived','migrating')),
161
+ parent_id INTEGER REFERENCES projects(id),
162
+ obsidian_link TEXT,
163
+ claude_notes_dir TEXT,
164
+ session_config TEXT,
165
+ created_at BIGINT NOT NULL,
166
+ updated_at BIGINT NOT NULL,
167
+ archived_at BIGINT
168
+ );
169
+
170
+ CREATE TABLE IF NOT EXISTS sessions (
171
+ id SERIAL PRIMARY KEY,
172
+ project_id INTEGER NOT NULL REFERENCES projects(id),
173
+ number INTEGER NOT NULL,
174
+ date TEXT NOT NULL,
175
+ slug TEXT NOT NULL,
176
+ title TEXT NOT NULL,
177
+ filename TEXT NOT NULL,
178
+ status TEXT NOT NULL DEFAULT 'open'
179
+ CHECK (status IN ('open','completed','compacted')),
180
+ claude_session_id TEXT,
181
+ token_count INTEGER,
182
+ created_at BIGINT NOT NULL,
183
+ closed_at BIGINT,
184
+ UNIQUE (project_id, number)
185
+ );
186
+
187
+ CREATE TABLE IF NOT EXISTS tags (
188
+ id SERIAL PRIMARY KEY,
189
+ name TEXT NOT NULL UNIQUE
190
+ );
191
+
192
+ CREATE TABLE IF NOT EXISTS project_tags (
193
+ project_id INTEGER NOT NULL REFERENCES projects(id),
194
+ tag_id INTEGER NOT NULL REFERENCES tags(id),
195
+ PRIMARY KEY (project_id, tag_id)
196
+ );
197
+
198
+ CREATE TABLE IF NOT EXISTS session_tags (
199
+ session_id INTEGER NOT NULL REFERENCES sessions(id),
200
+ tag_id INTEGER NOT NULL REFERENCES tags(id),
201
+ PRIMARY KEY (session_id, tag_id)
202
+ );
203
+
204
+ CREATE TABLE IF NOT EXISTS aliases (
205
+ alias TEXT PRIMARY KEY,
206
+ project_id INTEGER NOT NULL REFERENCES projects(id)
207
+ );
208
+
209
+ CREATE TABLE IF NOT EXISTS compaction_log (
210
+ id SERIAL PRIMARY KEY,
211
+ project_id INTEGER NOT NULL REFERENCES projects(id),
212
+ session_id INTEGER REFERENCES sessions(id),
213
+ trigger TEXT NOT NULL CHECK (trigger IN ('precompact','manual','end-session')),
214
+ files_written TEXT NOT NULL,
215
+ token_count INTEGER,
216
+ created_at BIGINT NOT NULL
217
+ );
218
+
219
+ CREATE TABLE IF NOT EXISTS links (
220
+ id SERIAL PRIMARY KEY,
221
+ session_id INTEGER NOT NULL REFERENCES sessions(id),
222
+ target_project_id INTEGER NOT NULL REFERENCES projects(id),
223
+ link_type TEXT NOT NULL DEFAULT 'related'
224
+ CHECK (link_type IN ('related','follow-up','reference')),
225
+ created_at BIGINT NOT NULL,
226
+ UNIQUE (session_id, target_project_id)
227
+ );
228
+
229
+ CREATE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);
230
+ CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
231
+ CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(type);
232
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
233
+ CREATE INDEX IF NOT EXISTS idx_sessions_date ON sessions(date);
234
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
235
+ CREATE INDEX IF NOT EXISTS idx_sessions_claude ON sessions(claude_session_id);
236
+ CREATE INDEX IF NOT EXISTS idx_pc_project ON project_tags(project_id);
237
+ ```
238
+
239
+ Notes on the translation:
240
+ - `INTEGER PRIMARY KEY AUTOINCREMENT` → `SERIAL PRIMARY KEY` (Postgres idiom already used
241
+ by `kg_triples`, `pai_files` etc.).
242
+ - SQLite's `INTEGER` epoch-ms timestamp columns → `BIGINT` (Postgres `INTEGER` is 32-bit
243
+ and `created_at`/`updated_at` are `Date.now()` ms since epoch, which overflows it).
244
+ `pai_chunks.updated_at` already uses this convention — consistent with existing tables.
245
+ Do **not** use `TIMESTAMP` here: callers construct these values with `Date.now()`, not
246
+ `NOW()`, and converting the value shape is out of scope for a storage-backend swap.
247
+ `kg_triples.created_at` is the one existing exception (`TIMESTAMP DEFAULT
248
+ CURRENT_TIMESTAMP`) — inconsistent with the rest of the schema but pre-existing; not
249
+ changed here.
250
+ - FK constraints are declared inline (Postgres allows forward + circular-safe ordering
251
+ here since `projects.parent_id` self-references after the table exists); SQLite's
252
+ `PRAGMA foreign_keys = ON` behavior is enforced unconditionally in Postgres, no pragma
253
+ needed.
254
+ - `schema_version` (SQLite-only bookkeeping table) has no Postgres equivalent — Postgres
255
+ migrations are tracked the same way `kg_triples` is: an `information_schema` existence
256
+ check in `runMigrations()`, not a version counter. Do not port `schema_version`.
257
+
258
+ ## 3. Data-access design
259
+
260
+ **Extend the existing pattern**, don't invent a new one. `src/storage/interface.ts` /
261
+ `sqlite.ts` / `postgres/backend.ts` already do exactly this for the federation/vault data.
262
+ Do the same for the registry and for `kg_entities`:
263
+
264
+ - Add `RegistryBackend` (new file `src/storage/registry-interface.ts`, mirroring
265
+ `interface.ts`) with async methods for every distinct query used across the 64 files in
266
+ §1e: `getProject`, `listProjects`, `createProject`, `updateProject`, `findProjectBySlug`,
267
+ `resolveAlias`, `addAlias`, `removeAlias`, `createSession`, `getSession`, `listSessions`,
268
+ `updateSessionStatus`, `addTag`/`listTags`, `addSessionTag`, `addLink`, `listLinks`,
269
+ `appendCompactionLog`. Enumerating the full method list against every one of the 64 call
270
+ sites is implementation work (unit 3, §5), not a design decision — the shape is the same
271
+ "one interface, one method per operation, both backends implement it" pattern already
272
+ proven by `StorageBackend`.
273
+ - Add `kg_entities` methods (`upsertKgEntity`, `findKgEntity`, `listKgEntities`,
274
+ `updateEntityFeedbackWeight`) to the **existing** `StorageBackend` interface — it already
275
+ owns `kg_triples`-adjacent search and the federation DB; `kg_entities` belongs there, not
276
+ in a third interface. `SQLiteBackend` wraps today's `kg-entity.ts` functions; add a
277
+ `kg_entities` section to `src/storage/postgres/backend.ts` (same file that already has
278
+ `kg_triples` migrations).
279
+ - `src/storage/factory.ts` already returns the right backend for `storageBackend`; add a
280
+ matching `createRegistryBackend()` next to `createStorageBackend()` — same retry/outage
281
+ logic (§ existing `connectPostgres`), same "never silently fall back to SQLite" rule.
282
+
283
+ **Async conversion.** The registry API is 100% synchronous today (`better-sqlite3`). The
284
+ choice is between (a) making `RegistryBackend` async everywhere, converting all ~64 sync
285
+ callers to `await`, or (b) keeping registry sync-only by wrapping a Postgres client behind a
286
+ synchronous facade (not possible with `pg`'s async driver without a blocking bridge, which
287
+ `better-sqlite3`-style sync Postgres clients do not offer in Node without native blocking
288
+ I/O hacks that are worse than the migration itself). Pick (a): **make `RegistryBackend`
289
+ async**, matching `StorageBackend`, for one reason — `StorageBackend` already made this
290
+ choice for exactly the same problem (SQLite is sync, Postgres is not) and every caller of
291
+ `StorageBackend` in this codebase is already `async`/`await`-shaped (MCP tool handlers are
292
+ Promise-returning by SDK contract; daemon dispatcher and scheduler are already async; CLI
293
+ commander actions in this codebase are already declared `async (opts) => {...}` even where
294
+ they currently call sync registry functions inside — converting the inner call to `await`
295
+ is a one-line change per call site, not a structural rewrite). The only category that isn't
296
+ already async is the two shell hooks (§1c, addressed in §6) — everything else is already
297
+ sitting in an async function body waiting for the `await` to be added.
298
+ - Sqlite backend behavior is unchanged: `RegistryBackend`'s SQLite implementation wraps the
299
+ existing sync `db.prepare()` calls in `Promise.resolve(...)` (or plain `async` functions
300
+ that call the sync code) — no behavior change for `storageBackend: sqlite`, satisfying
301
+ "existing behaviour stays."
302
+ - No dual-write, no sync-from-sqlite at runtime — the factory picks one backend per process
303
+ start, exactly like `createStorageBackend()` does today.
304
+
305
+ ## 4. One-shot migration command: `pai db migrate-to-postgres`
306
+
307
+ New file `src/cli/commands/db-migrate.ts`, registered under the existing `pai db` command
308
+ group (`src/cli/commands/db.ts`). Idempotent (every write is `ON CONFLICT DO UPDATE`,
309
+ re-running after a partial failure resumes safely — same pattern as `docker/migrate-sqlite.ts`,
310
+ which this command supersedes and should absorb rather than duplicate).
311
+
312
+ Steps, in order, each one gated on the previous succeeding:
313
+
314
+ 1. **Pre-flight: bind-mount check.** Before touching any data: confirm
315
+ `docker inspect pai-pgvector --format '{{json .Mounts}}'` reports a `"Type":"bind"` mount
316
+ at `/var/lib/postgresql/data` with `Source` under the host `~/.pai/pgdata` path (not a
317
+ named volume, not absent). Refuse to proceed — exit non-zero, write nothing — if the
318
+ container isn't running, the mount type isn't `bind`, or the source path doesn't match
319
+ config. This is a hard gate: a named-volume or missing-mount Postgres means the "kept
320
+ as a backup" guarantee for the *destination* doesn't hold either.
321
+ 2. **Pre-flight: host-path backup (rollback point).** Run `pg_dump` against the target
322
+ database to a host path (e.g. `~/.pai/backups/pai_<user>-pre-migrate-<date>.dump`,
323
+ custom format `-Fc`) *before* any migration write. This is the rollback path: if
324
+ migration verification fails, `pg_restore --clean` from this dump undoes it. Refuse to
325
+ proceed if `pg_dump` fails or produces a zero-byte file.
326
+ 3. **Row-count baseline.** `SELECT COUNT(*)` on SQLite `kg_entities` and each registry
327
+ table; record alongside the equivalent Postgres counts (0 or pre-existing, since this
328
+ command is also idempotent against a partially-migrated database).
329
+ 4. **Migrate `kg_entities`** (federation.db → Postgres): batch `UPSERT` by `entity_id`,
330
+ same batching/transaction pattern as `docker/migrate-sqlite.ts`'s chunk migration.
331
+ 5. **Migrate registry tables**, in FK order: `projects` → `tags` → `sessions` →
332
+ `project_tags` → `session_tags` → `aliases` → `links` → `compaction_log`. `SERIAL` ids on
333
+ the Postgres side must preserve the SQLite integer ids (`INSERT ... (id, ...) VALUES
334
+ (...)` with explicit id, then `SELECT setval(...)` on each sequence afterward) — foreign
335
+ keys throughout the registry and in session/task-resolution code are stored as bare
336
+ integers, not slugs, so silently reassigning ids would break every existing reference.
337
+ 6. **Verify.** Re-run the `COUNT(*)` on both sides per table; if source count ≠ destination
338
+ count for anything, **stop, do not rename anything, exit non-zero** with the mismatching
339
+ table names. Add a spot check: for `kg_entities` and `projects`, pull 20 random ids from
340
+ SQLite and confirm every field matches the Postgres row byte-for-byte.
341
+ 7. **Stale-data reconciliation** (`memory_files`/`vault_files` — see superset check below):
342
+ report counts; do not silently drop rows that look like real content. Only rows that
343
+ match the indexer's *current* ignore patterns (dotfile/cache directories under a
344
+ registered project root) are treated as cruft and excluded from the "must preserve"
345
+ count — everything else migrates.
346
+ 8. **Rename, only after step 6 passes**: `federation.db` → `federation.db.migrated-<date>`,
347
+ `registry.db` → `registry.db.migrated-<date>`. Never `rm`. This is the point after which
348
+ `storageBackend: postgres` is safe to set in config — but this command does **not**
349
+ flip that config value itself; that stays a manual, explicit step for the operator.
350
+
351
+ ### Superset check (run 2026-09-22, read-only — SQLite via `better-sqlite3 readonly: true`,
352
+ Postgres via plain `SELECT`)
353
+
354
+ This is **not** a clean superset — flagging the actual numbers rather than the assumption in
355
+ the measured-state note:
356
+
357
+ | Comparison | SQLite | Postgres | SQLite paths absent from Postgres |
358
+ |---|---|---|---|
359
+ | `memory_files` vs `pai_files` (path-only, ignoring `project_id`) | 22,266 distinct paths | 32,722 distinct paths | **19,744** |
360
+ | `vault_files` vs Postgres `vault_files` | 8,254 | 84,645 | **3,371** |
361
+
362
+ Postgres has far more rows overall in both cases (different/additional projects indexed
363
+ since), but SQLite still contains real gaps. Diagnosis for `memory_files`: 2,835 of the
364
+ 19,744 missing paths sit under indexer-scope cruft (`.Trash/`, `.bun/install/cache/`,
365
+ `.npm/`, `.cargo/`, `.pyenv/`, other dotfile directories at a project root) — almost
366
+ certainly from before the indexer's ignore patterns were tightened; these are candidates for
367
+ exclusion, not migration. The remaining ~16,900 were not individually triaged in this
368
+ pass — the migration command (step 7) must classify each by running it through the
369
+ *current* indexer ignore-pattern check, not assume. Diagnosis for `vault_files`: the 3,371
370
+ missing paths were spot-checked and are not obviously cruft (real note paths under project
371
+ directories that exist in both systems) — likely genuinely stale (older vault-indexer run,
372
+ not yet re-indexed into Postgres), so these should be included in the migration rather than
373
+ dropped, pending a fresher `pai obsidian sync` pass which may supersede them naturally.
374
+
375
+ ## 5. Work breakdown (independent units, parallelizable — no overlapping files)
376
+
377
+ | Unit | Files | Proof |
378
+ |---|---|---|
379
+ | **1. Postgres DDL** | `src/storage/postgres/backend.ts` (`runMigrations()` additions for `kg_entities` + 7 registry tables) | `src/storage/postgres/init-sql.test.ts`-style test: fresh Postgres database, run migrations twice (idempotency), assert all 8 tables + indexes exist via `information_schema`. Before/after: `\dt` table count. |
380
+ | **2. `kg_entities` on `StorageBackend`** | `src/storage/interface.ts` (+4 methods), `src/storage/sqlite.ts` (wrap `kg-entity.ts`), `src/storage/postgres/backend.ts` (+4 methods), `src/memory/kg-entity.ts` callers (`kg-extraction.ts`, `kg-search.ts`, `mcp/tools/feedback.ts`) switched from raw `Database` param to `StorageBackend` param | Vitest: upsert same entity twice, assert `mention_count` increments, on both backends against a temp SQLite file and a temp Postgres database/schema (not the live one). |
381
+ | **3. `RegistryBackend` interface + SQLite impl** | new `src/storage/registry-interface.ts`, `src/storage/registry-sqlite.ts` (wraps existing `registry/db.ts` + `registry/merge.ts` logic); convert `src/cli/program.ts`'s `getDb()` to return a `RegistryBackend` | Vitest against a temp SQLite file: create project, add session, resolve alias — assert round-trip. This is the largest unit (defines the full method surface against all 64 call sites) — do this before unit 4. |
382
+ | **4. `RegistryBackend` Postgres impl** | `src/storage/registry-postgres.ts`, `src/storage/factory.ts` (`createRegistryBackend()`) | Same test suite as unit 3, run against temp Postgres schema — assert identical results to the SQLite impl (shared test file, backend passed as parameter). |
383
+ | **5. Caller conversion (registry)** | the 64 files in §1e — mechanical `db.prepare(...)` → `await registryBackend.method(...)`, one PR-sized slice per subdirectory (`cli/commands/project/*`, `cli/commands/session/*`, `cli/commands/registry/*`, `daemon/*`, `mcp/tools/*`) | Existing command-level tests (`project/here.test.ts`, `project/mcp-tools.test.ts`, `registry/merge.test.ts`, `workers/project-config.test.ts`) pass unmodified against both backends. |
384
+ | **6. Migration command** | `src/cli/commands/db-migrate.ts`, absorbs/retires `docker/migrate-sqlite.ts` | Dry run against a copy of the real `federation.db`/`registry.db` into a scratch Postgres database; assert row counts match and re-run is a no-op (idempotency). |
385
+ | **7. Boundary enforcement test** | new `src/storage/boundary.test.ts` | Scans `src/**/*.ts` excluding `*.test.ts` and everything under `src/storage/`; fails if any file imports `better-sqlite3` or `pg`, imports `openFederation`/`openRegistry`, or contains the literal strings `federation.db`/`registry.db`. Concretely: `readdirSync` walk + regex on file text, `expect(violations).toEqual([])`. This test only passes once units 1–6 are complete — it is the final gate, not something to add early and skip. |
386
+ | **8. Hooks (`session-stop.sh`, `pre-compact.sh`)** | see §6 — needs a decision, not purely mechanical | `pai db registry-touch --session-stop <args>` (or equivalent) exercised manually against both backends; hook script diffed for behavior parity. |
387
+
388
+ Units 1–2 and 3 can start in parallel (disjoint files). Unit 4 depends on 3. Unit 5 depends
389
+ on 3+4. Unit 6 depends on 1–4. Unit 7 depends on everything. Unit 8 can start any time after
390
+ unit 4 (needs the Postgres registry methods to exist) but is independent of unit 5's caller
391
+ sweep.
392
+
393
+ ## 6. Risks
394
+
395
+ - **Daemon restart ordering.** The daemon currently opens both `federation.db` and
396
+ `registry.db` at startup (`server.ts`, `dispatcher.ts`). Under Postgres, the daemon's
397
+ `waitForPostgres: true` retry loop already exists for the federation backend
398
+ (`src/storage/factory.ts`) — extend the same loop to gate registry backend creation too,
399
+ so the daemon doesn't start serving with a `RegistryBackend` pointed at a not-yet-ready
400
+ Postgres. Boot ordering (Docker Desktop before launchd-started daemon) is the same
401
+ pre-existing race the current retry logic already handles; no new mechanism needed, just
402
+ applying the existing one to a second backend.
403
+ - **Concurrent writers during migration.** `federation.db` is called out as "ONLY copy,
404
+ still being written live" for `kg_entities` — the daemon and any running session are
405
+ actively upserting entities. The migration command must require the daemon stopped (`pai
406
+ daemon stop`) before running, and check for it (refuse to proceed if `pai daemon status`
407
+ reports running), otherwise rows written during the row-count baseline (step 3) are
408
+ invisible to the migration but land in SQLite after — a source of silent data loss the
409
+ verification step (step 6) would not catch, since it re-reads SQLite counts fresh but a
410
+ row written after step 4's read and before step 8's rename would never be copied.
411
+ - **Tests that assume SQLite.** `project/here.test.ts` and `project/mcp-tools.test.ts`
412
+ construct `new Database(":memory:")` directly — these become tests of the SQLite
413
+ `RegistryBackend` implementation (unit 3), not of raw `better-sqlite3` usage in the
414
+ caller. `workers/project-config.test.ts` similarly. All three need their setup swapped to
415
+ go through the new interface rather than instantiating `better-sqlite3` inline — small,
416
+ mechanical, but must happen in lockstep with unit 3/5, not after, or they start failing
417
+ the boundary test (unit 7) as soon as it's added.
418
+ - **Shell hooks (§1c) have no Postgres client.** `sqlite3` is a CLI binary; there is no
419
+ equivalent zero-dependency `psql`-in-bash story without assuming `psql` is installed
420
+ (it isn't a stated PAI dependency today). Two options, not resolved here: (a) require
421
+ `psql` as a new host dependency for `storageBackend: postgres` deployments, or (b) add a
422
+ tiny `pai` subcommand the hooks shell out to (`pai hooks session-stop-write ...`), paying
423
+ a Node startup cost on every session-stop/pre-compact event. (b) is more consistent with
424
+ "no file outside `src/storage/` touches the database directly" and avoids a new external
425
+ dependency — recommended, but flagged as a decision for whoever picks up unit 8, not
426
+ decided unilaterally here.
427
+
428
+
429
+ ## 8. Decisions and finish sequence (owner-approved, 2026-09-22)
430
+
431
+ - Unit 8 decision: option (b). The two shell hooks shell out to a `pai` subcommand; no file outside `src/storage/` touches a database.
432
+ - Finish sequence, in order, nothing skipped:
433
+ 1. Stop `com.pai.pai-daemon` and `com.pai.task-scheduler` (launchd) so nothing writes SQLite or Postgres during the copy. Check `com.pai.claudaemon` and the per-session `dist/daemon-mcp` shims for SQLite access first.
434
+ 2. Preflight: `pai-pgvector` bind mount `~/.pai/pgdata -> /var/lib/postgresql/data` present; `pg_dump` of the configured database to a host path.
435
+ 3. `pai db migrate-to-postgres`: kg_entities, all registry tables, and the SQLite-only memory_files/memory_chunks (19,744 paths) and vault rows (3,371 paths) absent from Postgres. Refuse on any count mismatch.
436
+ 4. Verify: per-table counts (SQLite rows == Postgres rows for migrated sets, Postgres superset for memory/vault paths), spot checks, sequences setval'd.
437
+ 5. Cleanup: delete `~/.claude/pai/federation.db` (+ -wal/-shm) and `~/.claude/pai/registry.db` only after step 4 passes. Rollback = the step-2 pg_dump plus the CCC backup volume copy.
438
+ 6. Restart `com.pai.pai-daemon`, `com.pai.task-scheduler` (and `claudaemon` if it used SQLite); confirm the daemon log shows the Postgres backend and no SQLite file is recreated (`ls ~/.claude/pai/*.db` stays empty after a full scheduler cycle). Open sessions pick up the new MCP shim on their next restart.
@@ -0,0 +1,174 @@
1
+ # Registry method map — unit 3 → unit 5
2
+
3
+ `RegistryBackend` (`src/storage/registry-interface.ts`) enumerates every distinct
4
+ registry query used by the 64 caller files inventoried in `postgres-only.md` §1e.
5
+ This table is the mechanical checklist for unit 5: for each file, the methods it
6
+ will call once its `db.prepare(...)` calls are replaced with
7
+ `await registryBackend.method(...)`.
8
+
9
+ Method naming matches `registry-interface.ts` exactly. A file listed with
10
+ "(no direct registry access)" either takes an already-resolved row as a
11
+ parameter or only touches `federation.db` — out of scope for the registry
12
+ conversion.
13
+
14
+ ## Method list (82 methods)
15
+
16
+ **Projects — reads:** `getProjectById`, `getProjectBySlug`, `getProjectByAlias`,
17
+ `getProjectByRootPath`, `getProjectByEncodedDir`, `findProjectByCwdPrefix`,
18
+ `listProjects`, `listProjectsByPathLengthDesc`, `listProjectsWithSessionStats`,
19
+ `listNamedProjects`, `searchProjects`, `countProjects`,
20
+ `getMostRecentProjectUpdatedAt`, `countChildProjects`,
21
+ `findSiblingProjectsBySlugPattern`
22
+
23
+ **Projects — writes:** `createProject`, `createProjectWithSlugRetry`,
24
+ `updateProjectPath`, `updateProjectStatus`, `updateProjectDisplayName`,
25
+ `updateProjectType`, `updateProjectSessionConfig`, `updateProjectClaudeNotesDir`,
26
+ `updateProjectObsidianLink`, `updateProjectSlug`, `reassignProjectParent`,
27
+ `deleteProject`, `deleteProjectCascade`
28
+
29
+ **Tags:** `listTagsForProject`, `listAllTags`, `upsertTag`, `addProjectTag`,
30
+ `projectHasTag`, `deleteProjectTag`, `reassignProjectTag`, `listProjectTagIds`,
31
+ `countProjectTagsForProject`, `copyProjectTags`, `deleteProjectTagsForProject`
32
+
33
+ **Aliases:** `resolveAlias`, `listAliasesForProject`, `addAlias`, `removeAlias`,
34
+ `countAliasesForProject`, `moveProjectAliases`, `reassignAlias`, `listAliasMap`
35
+
36
+ **Sessions:** `getSessionById`, `getSessionByNumber`, `getLatestSessionForProject`,
37
+ `getMaxSessionNumber`, `listSessionsForProject`, `listSessions`,
38
+ `findSessionByFilename`, `sessionNumberTaken`, `createSession`,
39
+ `upsertSessionIfAbsent`, `updateSessionMeta`, `updateSessionNumber`,
40
+ `updateSessionFilename`, `moveSessionToProject`, `deleteSession`,
41
+ `backfillFoldedSession`, `countSessionsForProject`, `getMostRecentSessionDate`,
42
+ `getMostRecentSessionCreatedAt`
43
+
44
+ **Session tags:** `listSessionTagIds`, `sessionHasTag`, `addSessionTag`,
45
+ `deleteSessionTag`, `reassignSessionTag`, `listTagsForSession`
46
+
47
+ **Links:** `addLink`, `listLinksForSession`, `listLinksForProject`, `linkExists`,
48
+ `deleteLink`, `deleteLinksTargetingProject`, `deleteLinksFromProjectSessions`,
49
+ `retargetLink`, `moveLinkToSession`, `reassignLinksTarget`,
50
+ `countLinksForProject`, `deleteSelfLinksForProject`
51
+
52
+ **Compaction log:** `appendCompactionLog`, `countCompactionLogsForProject`,
53
+ `moveCompactionLogsByProject`, `moveCompactionLogsBySession`,
54
+ `deleteCompactionLogsForProject`
55
+
56
+ **Merge:** `planProjectMerge`, `applyProjectMerge` (wrap `src/registry/merge.ts`
57
+ unchanged)
58
+
59
+ ## Per-caller mapping
60
+
61
+ ### CLI project commands
62
+
63
+ | File | Methods |
64
+ |---|---|
65
+ | `src/cli/commands/project/index.ts` | (no direct registry access) |
66
+ | `src/cli/commands/project/commands.ts` | `getProjectBySlug`(conflict check), `listProjects`, `createProject`, `listProjectsWithSessionStats`, `countProjects`, `listSessionsForProject`, `updateProjectStatus`, `updateProjectPath`, `projectHasTag`, `addProjectTag`, `getProjectBySlug`(alias conflict), `addAlias`, `updateProjectDisplayName`, `updateProjectType`, `getProjectByEncodedDir` |
67
+ | `src/cli/commands/project/here.ts` | `listProjects`, `getProjectByEncodedDir`, `updateProjectPath`, `getProjectBySlug`, `createProject` |
68
+ | `src/cli/commands/project/helpers.ts` | `getProjectBySlug`, `getProjectByAlias`, `listProjects`, `listTagsForProject`, `listAliasesForProject`, `countSessionsForProject`, `getMostRecentSessionDate`, `upsertTag` |
69
+ | `src/cli/commands/project/health.ts` | `listProjectsWithSessionStats`, `updateProjectPath`, `updateProjectStatus` |
70
+ | `src/cli/commands/project/merge.ts` | `planProjectMerge`, `getProjectById`, `applyProjectMerge` |
71
+ | `src/cli/commands/project/unregister.ts` | `getProjectBySlug`+`countSessionsForProject` (or a dedicated read), `deleteProjectCascade` |
72
+ | `src/cli/commands/project/projects-index.ts` | delegates to `main-resolver.ts`/`helpers.ts`; direct: `updateProjectPath` |
73
+ | `src/cli/commands/project/session-config.ts` | `updateProjectSessionConfig`, `getProjectBySlug`(conflict), `resolveAlias`, `addAlias`, `removeAlias`, `countAliasesForProject`, `listNamedProjects` |
74
+ | `src/cli/commands/main-resolver.ts` | `listProjectsWithSessionStats` |
75
+ | `src/cli/commands/pick.ts` | `listProjectsWithSessionStats`, `updateProjectStatus` (resolve slug→id first) |
76
+ | `src/cli/commands/detect.ts` | `listProjectsByPathLengthDesc`, `countSessionsForProject`+`getMostRecentSessionDate` (or new combined read) |
77
+
78
+ ### CLI session commands
79
+
80
+ | File | Methods |
81
+ |---|---|
82
+ | `src/cli/commands/session/index.ts` | (no direct registry access) |
83
+ | `src/cli/commands/session/commands.ts` | `listSessions`, `updateSessionMeta`, `sessionHasTag`, `addSessionTag`, `getProjectBySlug`, `addLink`, `getProjectByEncodedDir` |
84
+ | `src/cli/commands/session/autosave.ts` | `getLatestSessionForProject` |
85
+ | `src/cli/commands/session/pause.ts` | `findProjectByCwdPrefix`, `getLatestSessionForProject`, `findSiblingProjectsBySlugPattern` |
86
+ | `src/cli/commands/session/handover.ts` | `getProjectBySlug`, `getLatestSessionForProject`, `getSessionByNumber` |
87
+ | `src/cli/commands/session/goto.ts` | via `session-scan.ts`: `listProjects` |
88
+ | `src/cli/commands/session/end.ts` | `findProjectByCwdPrefix` |
89
+ | `src/cli/commands/session/helpers.ts` | `getProjectBySlug`, `getLatestSessionForProject`, `getSessionByNumber`, `upsertTag`, `listTagsForSession` |
90
+ | `src/cli/commands/session/sessions-index.ts` | (no direct registry access) |
91
+ | `src/cli/commands/session-cleanup/index.ts` | via `scanner.ts`: `getProjectBySlug`, `listProjects` |
92
+ | `src/cli/commands/session-cleanup/scanner.ts` | `listProjects`, `getProjectBySlug`, `listSessionsForProject` |
93
+ | `src/cli/commands/session-cleanup/executor.ts` | `deleteSession`, `updateSessionMeta`, `updateSessionNumber`, `updateSessionFilename` |
94
+ | `src/cli/lib/session-scan.ts` | `listProjects` |
95
+ | `src/cli/lib/dedup-sessions.ts` | (no direct registry access) |
96
+
97
+ ### CLI registry commands + registry/merge.ts + registry/migrate.ts
98
+
99
+ | File | Methods |
100
+ |---|---|
101
+ | `src/cli/commands/registry/index.ts` | `countProjects`, `countSessionsForProject`(overall — new use of general count with no filter is `SELECT COUNT(*) FROM sessions`, map to a plain count call), `listAllTags`(count), `getMostRecentProjectUpdatedAt`, `getMostRecentSessionCreatedAt`, full wipe (`db.exec` of 8 DELETEs — stays a raw backend-level `resetRegistry()` composite if unit 5 needs it, not yet in the interface — flag for unit 5), `getProjectByRootPath` |
102
+ | `src/cli/commands/registry/scan.ts` | `updateProjectClaudeNotesDir`, `listProjects`, `getProjectByRootPath`, `getProjectBySlug`, `getProjectByEncodedDir`, `updateProjectPath`, `listProjects`(display_name=slug legacy — filter client-side), `updateProjectDisplayName`; plus `createProjectWithSlugRetry`/`upsertProjectByPath`-shaped calls via `utils.ts` |
103
+ | `src/cli/commands/registry/reconnect.ts` | `listProjectsWithSessionStats`, `updateProjectPath`(encoded_dir only) |
104
+ | `src/cli/commands/registry/dedupe.ts` | `countSessionsForProject`, `countCompactionLogsForProject`, `countAliasesForProject`, `countLinksForProject`, `countProjectTagsForProject`, `countChildProjects`, `listProjects`(all columns, order id), `listSessionTagIds`, `sessionHasTag`, `deleteSessionTag`, `reassignSessionTag`, `listLinksForSession`, `linkExists`, `deleteLink`, `moveLinkToSession`, `moveCompactionLogsBySession`, `backfillFoldedSession`, `deleteSession`, `getMaxSessionNumber`, `listSessionsForProject`, `findSessionByFilename`, `sessionNumberTaken`, `moveSessionToProject`, `moveCompactionLogsByProject`, `listAliasesForProject`, `resolveAlias`, `reassignAlias`, `listLinksForProject`, `deleteLink`, `retargetLink`, `listProjectTagIds`, `projectHasTag`, `deleteProjectTag`, `reassignProjectTag`, `reassignProjectParent`, `updateProjectClaudeNotesDir`, `updateProjectSessionConfig`, `deleteProject`, `updateProjectStatus`, `getProjectBySlug`(candidate check), `updateProjectSlug`, `getProjectByRootPath`, `updateProjectPath`, `listProjects` |
105
+ | `src/cli/commands/registry/migrate.ts` | via `utils.ts` upsert helpers |
106
+ | `src/cli/commands/registry/utils.ts` | `getProjectByEncodedDir`, `getProjectByRootPath`, `updateProjectPath`, `createProjectWithSlugRetry`, `getSessionByNumber`(existence via `sessionNumberTaken`), `createSession` |
107
+ | `src/registry/merge.ts` | internals of `planProjectMerge`/`applyProjectMerge` — not converted; called by `registry-sqlite.ts` directly |
108
+ | `src/registry/migrate.ts` | `createProjectWithSlugRetry`(slug-retry insert), `getProjectBySlug`, `getProjectByRootPath`, `upsertSessionIfAbsent` |
109
+
110
+ ### CLI memory/obsidian/backup/config
111
+
112
+ | File | Methods |
113
+ |---|---|
114
+ | `src/cli/commands/memory/stats.ts` | `getProjectBySlug`, `getProjectById`(batch — loop or new `getProjectsByIds`, flag for unit 5) |
115
+ | `src/cli/commands/memory/search.ts` | `getProjectBySlug` |
116
+ | `src/cli/commands/memory/index-cmd.ts` | `getProjectBySlug`(status filter via opts) |
117
+ | `src/cli/commands/memory/embed.ts` | `getProjectBySlug` |
118
+ | `src/cli/commands/backup.ts` | (no direct registry access) |
119
+ | `src/cli/commands/restore.ts` | (no direct registry access) |
120
+ | `src/cli/commands/config.ts` | (no direct registry access) |
121
+ | `src/cli/commands/obsidian.ts` | (no direct registry access — delegates) |
122
+ | `src/obsidian/sync/generate.ts` | `listProjectsWithSessionStats`, `listTagsForProject`, `listAllTags`, `listProjectsWithSessionStats`(tagId) |
123
+ | `src/obsidian/sync/master.ts` | `listProjects`(status active, order slug) ×2 |
124
+ | `src/obsidian/sync/symlinks.ts` | `listProjects`(order status,slug), `updateProjectObsidianLink` |
125
+ | `src/obsidian/status.ts` | `listProjects`(status active) |
126
+ | `src/obsidian/vault-fixer.ts` | (no direct registry access — federation.db only) |
127
+
128
+ ### MCP tools
129
+
130
+ | File | Methods |
131
+ |---|---|
132
+ | `src/mcp/tools/registry.ts` | `searchProjects` |
133
+ | `src/mcp/tools/projects.ts` | `getProjectById`, `listProjects`(status/tag/limit), `listProjectsWithSessionStats` |
134
+ | `src/mcp/tools/sessions.ts` | `listSessions`(projectId/status/limit, no join needed — trim columns client-side) |
135
+ | `src/mcp/tools/wakeup.ts` | `getProjectBySlug` |
136
+ | `src/mcp/tools/types.ts` | `getProjectBySlug`(id lookup), `resolveAlias`, `getProjectByRootPath`, `listProjectsByPathLengthDesc`, `countSessionsForProject`, `getMostRecentSessionDate`, `listTagsForProject`, `listAliasesForProject` |
137
+
138
+ ### Daemon
139
+
140
+ | File | Methods |
141
+ |---|---|
142
+ | `src/daemon/daemon/dispatcher.ts` | (no direct registry access) |
143
+ | `src/daemon/daemon/handler.ts` | `countProjects`, `findProjectByCwdPrefix`, `getProjectBySlug` |
144
+ | `src/daemon/daemon/scheduler.ts` | `getProjectByRootPath`, `listProjects`(status active) |
145
+ | `src/daemon/daemon/server.ts` | `openRegistry`/`getRegistryBackend` only (no table queries) |
146
+ | `src/daemon/daemon/state.ts` | `openRegistry`/`getRegistryBackend` only |
147
+ | `src/daemon/daemon/types.ts` | (no direct registry access) |
148
+ | `src/daemon/session-summary-worker.ts` | `getProjectBySlug` |
149
+
150
+ ### Session/task/topic routing + workers
151
+
152
+ | File | Methods |
153
+ |---|---|
154
+ | `src/session/auto-route.ts` | `getProjectBySlug`(status filter) ×2 |
155
+ | `src/session/promote.ts` | `getProjectBySlug`+`getProjectByRootPath`+`getProjectByEncodedDir`(conflict check), `createProject` |
156
+ | `src/tasks/resolver.ts` | `listAliasMap` |
157
+ | `src/topics/detector.ts` | (no direct registry access — takes `StorageBackend`) |
158
+ | `src/workers/project-config.ts` | `listProjectsByPathLengthDesc`(excludeArchived) |
159
+
160
+ ## Gaps found during mapping (flag for unit 5, not fixed here)
161
+
162
+ - `registry/index.ts`'s full-wipe (`db.exec` of 8 `DELETE`s for `pai registry
163
+ rebuild`) has no single interface method yet — it is a rare, deliberately
164
+ destructive whole-table operation and does not belong next to the granular
165
+ CRUD methods above. Unit 5 should add one explicit `resetRegistry()` method
166
+ when it converts that file, not before.
167
+ - `memory/stats.ts`'s batch "project ids → slugs" lookup (`WHERE id IN (...)`)
168
+ is not in the interface; unit 5 can loop `getProjectById` (small N — global
169
+ stats span a handful of projects) or add a batch method if profiling shows
170
+ it matters.
171
+ - `unregister.ts` and `dedupe.ts` currently read `session_count` via a
172
+ correlated subquery in the same `SELECT`; the mapping above splits that into
173
+ a separate `countSessionsForProject` call. Behaviourally identical, one more
174
+ round trip per call site.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekmidian/pai",
3
- "version": "0.54.1",
3
+ "version": "0.55.0",
4
4
  "description": "PAI Knowledge OS — Personal AI Infrastructure with federated memory and project management",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",