@tekmidian/pai 0.42.5 → 0.43.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 (297) hide show
  1. package/ARCHITECTURE.md +9 -9
  2. package/README.md +26 -8
  3. package/dist/{aibroker-client-BP74gGwD.mjs → aibroker-client-Bn9SnGMa.mjs} +3 -3
  4. package/dist/{aibroker-client-BP74gGwD.mjs.map → aibroker-client-Bn9SnGMa.mjs.map} +1 -1
  5. package/dist/{aibroker-client-C6m71p6O.mjs → aibroker-client-D5NT0S1N.mjs} +1 -1
  6. package/dist/{auto-route-_5zzJz5q.mjs → auto-route-CcD0OWWB.mjs} +4 -4
  7. package/dist/{auto-route-_5zzJz5q.mjs.map → auto-route-CcD0OWWB.mjs.map} +1 -1
  8. package/dist/{chain-DTII7xeL.mjs → chain-CEC1a5mX.mjs} +5 -4
  9. package/dist/{chain-DTII7xeL.mjs.map → chain-CEC1a5mX.mjs.map} +1 -1
  10. package/dist/cli/index.mjs +23 -21
  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 +23 -21
  14. package/dist/{clusters-j0ZuNYp9.mjs → clusters-Cw_P6lO-.mjs} +2 -2
  15. package/dist/{clusters-j0ZuNYp9.mjs.map → clusters-Cw_P6lO-.mjs.map} +1 -1
  16. package/dist/{config-B64vFg14.mjs → config-DCu8THKi.mjs} +41 -9
  17. package/dist/{config-B64vFg14.mjs.map → config-DCu8THKi.mjs.map} +1 -1
  18. package/dist/config-DeqFqa1N.mjs +3 -0
  19. package/dist/config-DxMZjpvY.mjs +1289 -0
  20. package/dist/config-DxMZjpvY.mjs.map +1 -0
  21. package/dist/context-handover-cache-B9NKSjwR.mjs +3242 -0
  22. package/dist/context-handover-cache-B9NKSjwR.mjs.map +1 -0
  23. package/dist/daemon/index.mjs +23 -22
  24. package/dist/daemon/index.mjs.map +1 -1
  25. package/dist/{daemon-B8N1egl0.mjs → daemon-BhldpFvO.mjs} +52 -38
  26. package/dist/daemon-BhldpFvO.mjs.map +1 -0
  27. package/dist/daemon-QIvCXB5f.mjs +24 -0
  28. package/dist/daemon-mcp/index.mjs +144 -117
  29. package/dist/daemon-mcp/index.mjs.map +1 -1
  30. package/dist/db-BOBfewFH.mjs +3 -0
  31. package/dist/{db-a1ixZQjr.mjs → db-DJ8rk0VM.mjs} +15 -6
  32. package/dist/{db-a1ixZQjr.mjs.map → db-DJ8rk0VM.mjs.map} +1 -1
  33. package/dist/db-DbY2O8QO.mjs +3 -0
  34. package/dist/{db-Ca5qfsMC.mjs → db-Ddns80yf.mjs} +15 -6
  35. package/dist/{db-Ca5qfsMC.mjs.map → db-Ddns80yf.mjs.map} +1 -1
  36. package/dist/{detect-CdaA48EI.mjs → detect-Bf2z-oKB.mjs} +1 -1
  37. package/dist/{detect-CdaA48EI.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
  38. package/dist/detector-CkwkXuGh.mjs +5 -0
  39. package/dist/{detector-C73Nxm94.mjs → detector-DWXkeTlk.mjs} +2 -2
  40. package/dist/{detector-C73Nxm94.mjs.map → detector-DWXkeTlk.mjs.map} +1 -1
  41. package/dist/{embeddings-CEBGrzwu.mjs → embeddings-ClqKtLZ8.mjs} +1 -1
  42. package/dist/{embeddings-DOLZnT1X.mjs → embeddings-DvRKcQyt.mjs} +1 -1
  43. package/dist/{embeddings-DOLZnT1X.mjs.map → embeddings-DvRKcQyt.mjs.map} +1 -1
  44. package/dist/factory-47Qf_df7.mjs +3 -0
  45. package/dist/{factory-DEb9NmHJ.mjs → factory-DNYAqjH0.mjs} +8 -8
  46. package/dist/{factory-DEb9NmHJ.mjs.map → factory-DNYAqjH0.mjs.map} +1 -1
  47. package/dist/{fallback-oJl3BaIN.mjs → fallback-BQJ8PbCK.mjs} +185 -123
  48. package/dist/fallback-BQJ8PbCK.mjs.map +1 -0
  49. package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
  50. package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
  51. package/dist/hooks/capture-all-events.mjs +117 -34
  52. package/dist/hooks/capture-all-events.mjs.map +4 -4
  53. package/dist/hooks/capture-session-summary.mjs +110 -33
  54. package/dist/hooks/capture-session-summary.mjs.map +4 -4
  55. package/dist/hooks/capture-tool-output.mjs +105 -28
  56. package/dist/hooks/capture-tool-output.mjs.map +4 -4
  57. package/dist/hooks/cleanup-session-files.mjs +54 -13
  58. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  59. package/dist/hooks/context-compression-hook.mjs +7565 -122
  60. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  61. package/dist/hooks/initialize-session.mjs +55 -14
  62. package/dist/hooks/initialize-session.mjs.map +2 -2
  63. package/dist/hooks/inject-observations.mjs +79 -18
  64. package/dist/hooks/inject-observations.mjs.map +3 -3
  65. package/dist/hooks/load-core-context.mjs +55 -14
  66. package/dist/hooks/load-core-context.mjs.map +2 -2
  67. package/dist/hooks/load-project-context.mjs +207 -122
  68. package/dist/hooks/load-project-context.mjs.map +4 -4
  69. package/dist/hooks/mcp-deferred-gate.mjs +54 -13
  70. package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
  71. package/dist/hooks/mcp-deferred-reminder.mjs +9 -0
  72. package/dist/hooks/mcp-deferred-reminder.mjs.map +3 -3
  73. package/dist/hooks/observe.mjs +54 -13
  74. package/dist/hooks/observe.mjs.map +2 -2
  75. package/dist/hooks/post-compact-inject.mjs +7419 -16
  76. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  77. package/dist/hooks/route-agents-to-worker.mjs +7778 -171
  78. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  79. package/dist/hooks/route-edits-to-worker.mjs +90 -0
  80. package/dist/hooks/route-edits-to-worker.mjs.map +7 -0
  81. package/dist/hooks/security-validator.mjs +147 -6
  82. package/dist/hooks/security-validator.mjs.map +4 -4
  83. package/dist/hooks/session-commands.mjs +9 -0
  84. package/dist/hooks/session-commands.mjs.map +3 -3
  85. package/dist/hooks/stop-hook.mjs +182 -105
  86. package/dist/hooks/stop-hook.mjs.map +4 -4
  87. package/dist/hooks/subagent-stop-hook.mjs +3 -0
  88. package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
  89. package/dist/hooks/sync-todo-to-md.mjs +54 -13
  90. package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
  91. package/dist/hooks/update-tab-on-action.mjs +3 -0
  92. package/dist/hooks/update-tab-on-action.mjs.map +1 -1
  93. package/dist/hooks/update-tab-titles.mjs +3 -0
  94. package/dist/hooks/update-tab-titles.mjs.map +1 -1
  95. package/dist/hooks/whisper-reinject.mjs +11 -0
  96. package/dist/hooks/whisper-reinject.mjs.map +3 -3
  97. package/dist/hooks/whisper-rules.mjs +134 -29
  98. package/dist/hooks/whisper-rules.mjs.map +4 -4
  99. package/dist/hooks/worker-proxy.mjs +7737 -117
  100. package/dist/hooks/worker-proxy.mjs.map +4 -4
  101. package/dist/hooks/worker-status-line.mjs +7883 -169
  102. package/dist/hooks/worker-status-line.mjs.map +4 -4
  103. package/dist/hooks/worker-supervision.mjs +7721 -115
  104. package/dist/hooks/worker-supervision.mjs.map +4 -4
  105. package/dist/index.d.mts +4 -2
  106. package/dist/index.d.mts.map +1 -1
  107. package/dist/index.mjs +10 -10
  108. package/dist/{indexer-backend-BPTzKFZU.mjs → indexer-backend-CdvCJCkD.mjs} +3 -3
  109. package/dist/{indexer-backend-BPTzKFZU.mjs.map → indexer-backend-CdvCJCkD.mjs.map} +1 -1
  110. package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C6NryMMb.mjs} +2 -2
  111. package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C6NryMMb.mjs.map} +1 -1
  112. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-BSjM5xZh.mjs} +1 -1
  113. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-BSjM5xZh.mjs.map} +1 -1
  114. package/dist/{latent-ideas-CtSca4qG.mjs → latent-ideas-BfL4nCQ3.mjs} +4 -4
  115. package/dist/{latent-ideas-CtSca4qG.mjs.map → latent-ideas-BfL4nCQ3.mjs.map} +1 -1
  116. package/dist/{link-boost-Bucnz3n8.mjs → link-boost-C5PaNdzm.mjs} +1 -1
  117. package/dist/{link-boost-Bucnz3n8.mjs.map → link-boost-C5PaNdzm.mjs.map} +1 -1
  118. package/dist/main-resolver-CnfnWa8C.mjs +7 -0
  119. package/dist/{main-resolver-DnkxPYkr.mjs → main-resolver-DvYu9jbl.mjs} +21 -11
  120. package/dist/main-resolver-DvYu9jbl.mjs.map +1 -0
  121. package/dist/{migrate-Cjzeefn9.mjs → migrate-CfERwXhY.mjs} +2 -2
  122. package/dist/{migrate-Cjzeefn9.mjs.map → migrate-CfERwXhY.mjs.map} +1 -1
  123. package/dist/{neighborhood-CM7Jgb51.mjs → neighborhood-CETR_P4y.mjs} +2 -2
  124. package/dist/{neighborhood-CM7Jgb51.mjs.map → neighborhood-CETR_P4y.mjs.map} +1 -1
  125. package/dist/{note-context-DCwE2A-M.mjs → note-context-DCcRWBoN.mjs} +1 -1
  126. package/dist/{note-context-DCwE2A-M.mjs.map → note-context-DCcRWBoN.mjs.map} +1 -1
  127. package/dist/pai-home-ChAOupZc.mjs +164 -0
  128. package/dist/pai-home-ChAOupZc.mjs.map +1 -0
  129. package/dist/{pai-marker-CHtbJMwJ.mjs → pai-marker-B20KqhA8.mjs} +1 -1
  130. package/dist/{pai-marker-CHtbJMwJ.mjs.map → pai-marker-B20KqhA8.mjs.map} +1 -1
  131. package/dist/{planner-D5Ui7DAm.mjs → planner-BlKGgpWI.mjs} +7 -6
  132. package/dist/{planner-D5Ui7DAm.mjs.map → planner-BlKGgpWI.mjs.map} +1 -1
  133. package/dist/{postgres-CtOzfoQU.mjs → postgres-CUSXWgTG.mjs} +2 -2
  134. package/dist/{postgres-CtOzfoQU.mjs.map → postgres-CUSXWgTG.mjs.map} +1 -1
  135. package/dist/{program-zpoYnJGz.mjs → program-C5jRQ1kg.mjs} +8880 -5913
  136. package/dist/program-C5jRQ1kg.mjs.map +1 -0
  137. package/dist/query-feedback-C7rSfiXU.mjs +3 -0
  138. package/dist/{query-feedback-Dqz-HC__.mjs → query-feedback-CYyOGi3y.mjs} +20 -6
  139. package/dist/query-feedback-CYyOGi3y.mjs.map +1 -0
  140. package/dist/{reranker-CwTCNsgA.mjs → reranker-B5qUTamW.mjs} +1 -1
  141. package/dist/{reranker-xPm04PXx.mjs → reranker-HHmhZk6v.mjs} +1 -1
  142. package/dist/{reranker-xPm04PXx.mjs.map → reranker-HHmhZk6v.mjs.map} +1 -1
  143. package/dist/router-CremHKTI.mjs +3 -0
  144. package/dist/{router-DD6D4j31.mjs → router-DBAZfE7X.mjs} +2 -2
  145. package/dist/{router-DD6D4j31.mjs.map → router-DBAZfE7X.mjs.map} +1 -1
  146. package/dist/{run-D7K5UALn.mjs → run-ByBKowN5.mjs} +980 -247
  147. package/dist/run-ByBKowN5.mjs.map +1 -0
  148. package/dist/{runtime-paths-rni52zHX.mjs → runtime-paths-B0P1TvUr.mjs} +1 -1
  149. package/dist/{runtime-paths-rni52zHX.mjs.map → runtime-paths-B0P1TvUr.mjs.map} +1 -1
  150. package/dist/{search-Rpk1cSBC.mjs → search-B69cQwDB.mjs} +3 -3
  151. package/dist/{search-Rpk1cSBC.mjs.map → search-B69cQwDB.mjs.map} +1 -1
  152. package/dist/{search-CfPpJAWQ.mjs → search-fb-5ovj4.mjs} +2 -2
  153. package/dist/{server-D4vAieF4.mjs → server-Ch99xt5K.mjs} +15 -14
  154. package/dist/server-Ch99xt5K.mjs.map +1 -0
  155. package/dist/skills/Advisor/SKILL.md +3 -3
  156. package/dist/skills/Providers/SKILL.md +30 -0
  157. package/dist/skills/Reconstruct/SKILL.md +1 -1
  158. package/dist/skills/VaultContext/SKILL.md +1 -1
  159. package/dist/skills/Whisper/SKILL.md +3 -3
  160. package/dist/skills/Worker/SKILL.md +1 -1
  161. package/dist/{sources-CS43CpuS.mjs → sources-KBGEA_E6.mjs} +2 -2
  162. package/dist/{sources-CS43CpuS.mjs.map → sources-KBGEA_E6.mjs.map} +1 -1
  163. package/dist/{sqlite-Cq3gJcq9.mjs → sqlite-By6r2YhZ.mjs} +3 -3
  164. package/dist/{sqlite-Cq3gJcq9.mjs.map → sqlite-By6r2YhZ.mjs.map} +1 -1
  165. package/dist/{state-CY6I-Hnc.mjs → state-DnYGRmS7.mjs} +1 -1
  166. package/dist/{state-BRdB-Bf1.mjs → state-PJrgfDLW.mjs} +1 -1
  167. package/dist/{state-BRdB-Bf1.mjs.map → state-PJrgfDLW.mjs.map} +1 -1
  168. package/dist/{stop-words-Hfu8u22w.mjs → stop-words-BaMEGVeY.mjs} +1 -1
  169. package/dist/{stop-words-Hfu8u22w.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
  170. package/dist/{sync-BWbe8JTg.mjs → sync-Btcb6xA4.mjs} +3 -3
  171. package/dist/{sync-BWbe8JTg.mjs.map → sync-Btcb6xA4.mjs.map} +1 -1
  172. package/dist/{themes-B7h9YDiy.mjs → themes-BVoAnLZj.mjs} +3 -3
  173. package/dist/{themes-B7h9YDiy.mjs.map → themes-BVoAnLZj.mjs.map} +1 -1
  174. package/dist/{tools-BW3NLMPk.mjs → tools-CAG2sUjf.mjs} +4 -3
  175. package/dist/{tools-LlzcV_eK.mjs → tools-DiRC5glA.mjs} +30 -233
  176. package/dist/tools-DiRC5glA.mjs.map +1 -0
  177. package/dist/{trace-u4hT-pLD.mjs → trace-Cy8quQTm.mjs} +1 -1
  178. package/dist/{trace-u4hT-pLD.mjs.map → trace-Cy8quQTm.mjs.map} +1 -1
  179. package/dist/{utils-DhMex3Ox.mjs → utils-BD8g8oSf.mjs} +1 -1
  180. package/dist/{utils-9Err2RBW.mjs → utils-CJymHkYm.mjs} +1 -1
  181. package/dist/{utils-9Err2RBW.mjs.map → utils-CJymHkYm.mjs.map} +1 -1
  182. package/dist/{vault-indexer-mata9S7b.mjs → vault-indexer-C5i-OAyr.mjs} +2 -2
  183. package/dist/{vault-indexer-mata9S7b.mjs.map → vault-indexer-C5i-OAyr.mjs.map} +1 -1
  184. package/dist/wakeup-BfElduEU.mjs +231 -0
  185. package/dist/wakeup-BfElduEU.mjs.map +1 -0
  186. package/dist/work-queue-worker-BA5IDBwQ.mjs +567 -0
  187. package/dist/work-queue-worker-BA5IDBwQ.mjs.map +1 -0
  188. package/dist/work-queue-worker-DLM2wlqM.mjs +14 -0
  189. package/dist/{zettelkasten-F0D_XfQ4.mjs → zettelkasten-BBjA7GcP.mjs} +4 -4
  190. package/dist/{zettelkasten-F0D_XfQ4.mjs.map → zettelkasten-BBjA7GcP.mjs.map} +1 -1
  191. package/docs/commands/README.md +32 -3
  192. package/docs/commands/audit.md +113 -0
  193. package/docs/commands/backup.md +2 -2
  194. package/docs/commands/clear-names.md +1 -1
  195. package/docs/commands/config.md +59 -0
  196. package/docs/commands/daemon.md +1 -1
  197. package/docs/commands/db.md +1 -1
  198. package/docs/commands/end.md +1 -1
  199. package/docs/commands/help.md +1 -1
  200. package/docs/commands/identity.md +1 -1
  201. package/docs/commands/kg.md +1 -1
  202. package/docs/commands/launch.md +45 -0
  203. package/docs/commands/mcp.md +1 -1
  204. package/docs/commands/memory.md +3 -3
  205. package/docs/commands/notify.md +1 -1
  206. package/docs/commands/observation.md +1 -1
  207. package/docs/commands/obsidian.md +1 -1
  208. package/docs/commands/pause.md +1 -1
  209. package/docs/commands/project.md +46 -1
  210. package/docs/commands/projects.md +46 -1
  211. package/docs/commands/registry.md +1 -1
  212. package/docs/commands/restore.md +1 -1
  213. package/docs/commands/session.md +1 -1
  214. package/docs/commands/sessions.md +1 -1
  215. package/docs/commands/setup.md +1 -1
  216. package/docs/commands/shell-init.md +1 -1
  217. package/docs/commands/skill.md +1 -1
  218. package/docs/commands/task.md +1 -1
  219. package/docs/commands/topic.md +1 -1
  220. package/docs/commands/update.md +1 -1
  221. package/docs/commands/worker.md +97 -6
  222. package/docs/commands/zettel.md +1 -1
  223. package/docs/provider-abstraction.md +1 -1
  224. package/docs/provider-independence-details.md +8 -5
  225. package/docs/task-bus.md +1 -1
  226. package/docs/worker.md +136 -83
  227. package/docs/workers-config.md +446 -0
  228. package/package.json +4 -2
  229. package/scripts/build-hooks.mjs +58 -3
  230. package/src/hooks/pre-compact.sh +5 -2
  231. package/src/hooks/session-stop.sh +29 -4
  232. package/src/hooks/ts/capture-all-events.ts +3 -3
  233. package/src/hooks/ts/lib/advisor-budget.test.ts +98 -0
  234. package/src/hooks/ts/lib/advisor-budget.ts +90 -0
  235. package/src/hooks/ts/lib/agent-gate.test.ts +101 -0
  236. package/src/hooks/ts/lib/agent-gate.ts +112 -0
  237. package/src/hooks/ts/lib/context-fill.ts +2 -2
  238. package/src/hooks/ts/lib/edit-gate.test.ts +52 -0
  239. package/src/hooks/ts/lib/edit-gate.ts +51 -0
  240. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +8 -0
  241. package/src/hooks/ts/lib/pai-paths.test.ts +93 -0
  242. package/src/hooks/ts/lib/pai-paths.ts +168 -24
  243. package/src/hooks/ts/lib/project-utils/index.ts +1 -0
  244. package/src/hooks/ts/lib/project-utils/paths.ts +5 -4
  245. package/src/hooks/ts/lib/project-utils.ts +1 -0
  246. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +2 -2
  247. package/src/hooks/ts/post-tool-use/whisper-reinject.test.ts +55 -0
  248. package/src/hooks/ts/post-tool-use/whisper-reinject.ts +3 -0
  249. package/src/hooks/ts/pre-compact/context-compression-hook.ts +1 -1
  250. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +46 -99
  251. package/src/hooks/ts/pre-tool-use/route-edits-to-worker.test.ts +47 -0
  252. package/src/hooks/ts/pre-tool-use/route-edits-to-worker.ts +96 -0
  253. package/src/hooks/ts/pre-tool-use/security-validator.ts +2 -1
  254. package/src/hooks/ts/session-end/capture-session-summary.ts +4 -4
  255. package/src/hooks/ts/session-start/initialize-session.ts +3 -3
  256. package/src/hooks/ts/session-start/inject-observations.test.ts +56 -0
  257. package/src/hooks/ts/session-start/inject-observations.ts +28 -8
  258. package/src/hooks/ts/session-start/load-core-context.ts +4 -4
  259. package/src/hooks/ts/session-start/load-project-context.ts +2 -2
  260. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -0
  261. package/src/hooks/ts/session-start/session-commands.ts +4 -0
  262. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +36 -0
  263. package/src/hooks/ts/stop/stop-hook.ts +6 -11
  264. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +49 -0
  265. package/src/hooks/ts/user-prompt/whisper-rules.ts +88 -47
  266. package/src/hooks/ztk-auto.sh +28 -0
  267. package/src/hooks/ztk-auto.test.ts +72 -0
  268. package/src/hooks/ztk-passthrough.mjs +71 -0
  269. package/statusline-command.sh +277 -116
  270. package/templates/agent-prefs.example.md +8 -8
  271. package/templates/claude-md.template.md +1 -1
  272. package/templates/voices.example.json +3 -3
  273. package/dist/config-C_ErGddD.mjs +0 -3
  274. package/dist/config-_lQI_W08.mjs +0 -640
  275. package/dist/config-_lQI_W08.mjs.map +0 -1
  276. package/dist/context-handover-cache-pNFdBNrU.mjs +0 -1548
  277. package/dist/context-handover-cache-pNFdBNrU.mjs.map +0 -1
  278. package/dist/daemon-B8N1egl0.mjs.map +0 -1
  279. package/dist/daemon-DHfyoTlk.mjs +0 -23
  280. package/dist/db-O-cyAPfS.mjs +0 -3
  281. package/dist/db-XEwJbuGO.mjs +0 -3
  282. package/dist/detector-C-7KRi42.mjs +0 -5
  283. package/dist/factory-Dbo8yNPi.mjs +0 -3
  284. package/dist/fallback-oJl3BaIN.mjs.map +0 -1
  285. package/dist/main-resolver-BAfOMppW.mjs +0 -7
  286. package/dist/main-resolver-DnkxPYkr.mjs.map +0 -1
  287. package/dist/program-zpoYnJGz.mjs.map +0 -1
  288. package/dist/query-feedback-DGoVRaWD.mjs +0 -3
  289. package/dist/query-feedback-Dqz-HC__.mjs.map +0 -1
  290. package/dist/router-BTIGZR2_.mjs +0 -3
  291. package/dist/run-D7K5UALn.mjs.map +0 -1
  292. package/dist/server-D4vAieF4.mjs.map +0 -1
  293. package/dist/tools-LlzcV_eK.mjs.map +0 -1
  294. package/dist/work-queue-worker-AEmfVE0F.mjs +0 -14
  295. package/dist/work-queue-worker-DIT-VLSh.mjs +0 -2136
  296. package/dist/work-queue-worker-DIT-VLSh.mjs.map +0 -1
  297. /package/dist/{indexer-D7MvSQPY.mjs → indexer-C5Le7XE7.mjs} +0 -0
@@ -1,1548 +0,0 @@
1
- import { _ as warn, c as ok, i as err, n as dim, r as encodeDir, s as now, t as bold } from "./utils-9Err2RBW.mjs";
2
- import { a as slugify, i as parseSessionFilename, n as decodeEncodedDir, t as buildEncodedDirMap } from "./migrate-Cjzeefn9.mjs";
3
- import { n as ensurePaiMarker, t as discoverPaiMarkers } from "./pai-marker-CHtbJMwJ.mjs";
4
- import { _ as readWorkersSection, l as defaultWorkersConfig, v as resolveModelCapability } from "./config-_lQI_W08.mjs";
5
- import { i as kgAdd, o as kgInvalidate, r as upsertKgEntity, s as kgQuery } from "./kg-entity-r8duqhi9.mjs";
6
- import { _ as workersLogDir, f as ensureNoMcpConfig, i as buildRunEnv, n as ensureProxyRunning, t as DEFAULT_PROXY_PORT, u as resolveTarget } from "./server-D4vAieF4.mjs";
7
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
9
- import { basename, join, resolve } from "node:path";
10
-
11
- //#region src/registry/moved.ts
12
- /**
13
- * moved.ts — reconnect a project to transcripts that moved out from under it.
14
- *
15
- * The registry records an `encoded_dir`: the name Claude Code gave the folder
16
- * holding a project's transcripts. It is written once, when the project is
17
- * added, and nothing updates it when the project moves. So a project that has
18
- * been relocated points at a directory that is empty or gone, and every lookup
19
- * — checkpoints, handovers, session digests — quietly returns nothing.
20
- *
21
- * `resolveTranscriptDir` already re-derives the name from the project's current
22
- * root path, which fixes the case where the encoding is stale but the path is
23
- * right. It cannot fix the case where the ENCODING RULE itself does not
24
- * reproduce the folder name: iCloud paths with `~` in them, emoji segments, or
25
- * a folder Claude Code created under a path the project no longer has.
26
- *
27
- * This module answers that case by asking the transcripts instead of guessing.
28
- * Every transcript entry records the `cwd` it was written in, so the mapping
29
- * from a project root to its transcript folder is a fact sitting on disk rather
30
- * than something to be inferred from a naming convention. Reading it is slower
31
- * than a string transform and it is right, which is the correct trade for a
32
- * repair that runs on demand.
33
- */
34
- /** Where Claude Code keeps per-project transcript folders. */
35
- function claudeProjectsDir() {
36
- return join(homedir(), ".claude", "projects");
37
- }
38
- /**
39
- * Every transcript file for one project folder — live and archived.
40
- *
41
- * The archived half matters: `session-stop` moves all but the newest transcript
42
- * into `sessions/`, so a folder whose work is finished has an empty top level
43
- * and everything underneath. Counting only the top level reports a busy project
44
- * as unused, which is how an earlier audit of this same problem overstated the
45
- * breakage by a factor of three.
46
- */
47
- function transcriptFiles(projectDir) {
48
- const out = [];
49
- for (const dir of [projectDir, join(projectDir, "sessions")]) {
50
- if (!existsSync(dir)) continue;
51
- try {
52
- for (const entry of readdirSync(dir)) if (entry.endsWith(".jsonl")) out.push(join(dir, entry));
53
- } catch {}
54
- }
55
- return out;
56
- }
57
- /**
58
- * The working directory a transcript was recorded in.
59
- *
60
- * Reads only the head of the file. `cwd` is present on the first entry and does
61
- * not change within a session, so scanning further would cost time to learn
62
- * nothing. Returns null rather than throwing: a truncated or half-written
63
- * transcript is normal for a session that is still running.
64
- */
65
- function cwdOfTranscript(file, maxBytes = 64 * 1024) {
66
- let head;
67
- try {
68
- head = readFileSync(file).subarray(0, maxBytes).toString("utf-8");
69
- } catch {
70
- return null;
71
- }
72
- for (const line of head.split("\n")) {
73
- if (!line.trim()) continue;
74
- try {
75
- const cwd = JSON.parse(line).cwd;
76
- if (cwd) return cwd;
77
- } catch {
78
- continue;
79
- }
80
- }
81
- return null;
82
- }
83
- /**
84
- * Map every working directory seen on disk to the folders that hold its
85
- * transcripts.
86
- *
87
- * A directory can legitimately map to more than one folder — Claude Code's
88
- * encoding is lossy, so two different paths can collide, and a project moved
89
- * and moved back leaves both. Callers pick; this reports.
90
- */
91
- function scanTranscriptFolders(projectsDir = claudeProjectsDir(), sampleFilesPerFolder = 8) {
92
- const byCwd = /* @__PURE__ */ new Map();
93
- if (!existsSync(projectsDir)) return byCwd;
94
- let entries;
95
- try {
96
- entries = readdirSync(projectsDir);
97
- } catch {
98
- return byCwd;
99
- }
100
- for (const name of entries) {
101
- const dir = join(projectsDir, name);
102
- try {
103
- if (!statSync(dir).isDirectory()) continue;
104
- } catch {
105
- continue;
106
- }
107
- const files = transcriptFiles(dir);
108
- if (files.length === 0) continue;
109
- const mtimes = /* @__PURE__ */ new Map();
110
- for (const f of files) try {
111
- mtimes.set(f, statSync(f).mtimeMs);
112
- } catch {
113
- mtimes.set(f, 0);
114
- }
115
- const ordered = files.sort((a, b) => (mtimes.get(b) ?? 0) - (mtimes.get(a) ?? 0));
116
- const newest = mtimes.get(ordered[0]) ?? 0;
117
- const tally = /* @__PURE__ */ new Map();
118
- let sampled = 0;
119
- for (const f of ordered.slice(0, sampleFilesPerFolder)) {
120
- const cwd = cwdOfTranscript(f);
121
- if (!cwd) continue;
122
- sampled++;
123
- tally.set(cwd, (tally.get(cwd) ?? 0) + 1);
124
- }
125
- for (const [cwd, matching] of tally) {
126
- const record = {
127
- name,
128
- count: files.length,
129
- newest,
130
- matching,
131
- total: sampled
132
- };
133
- const list = byCwd.get(cwd);
134
- if (list) list.push(record);
135
- else byCwd.set(cwd, [record]);
136
- }
137
- }
138
- return byCwd;
139
- }
140
- /**
141
- * Which projects point at the wrong transcript folder, and where they belong.
142
- *
143
- * A project is only reported when its stored folder yields nothing AND its root
144
- * path is recorded as a `cwd` somewhere else. Both halves matter: without the
145
- * first this would rewrite entries that work, and without the second it would
146
- * have nothing better to offer than the value already there.
147
- *
148
- * `resolvesNow` is supplied by the caller so this module does not have to
149
- * duplicate the resolver's fallback logic — a project the shipped resolver
150
- * already handles is not broken and must not be "repaired".
151
- */
152
- function findMovedProjects(rows, byCwd, resolvesNow) {
153
- const out = [];
154
- for (const row of rows) {
155
- if (resolvesNow(row)) continue;
156
- const candidates = byCwd.get(row.root_path);
157
- if (!candidates || candidates.length === 0) continue;
158
- const owned = candidates.filter((c) => c.matching * 2 > c.total);
159
- if (owned.length === 0) continue;
160
- const best = [...owned].sort((a, b) => b.count - a.count || b.newest - a.newest)[0];
161
- if (best.name === row.encoded_dir) continue;
162
- out.push({
163
- id: row.id,
164
- slug: row.slug,
165
- rootPath: row.root_path,
166
- storedDir: row.encoded_dir,
167
- correctDir: best.name,
168
- transcripts: best.count,
169
- sessions: row.sessions
170
- });
171
- }
172
- return out.sort((a, b) => b.sessions - a.sessions || b.transcripts - a.transcripts);
173
- }
174
-
175
- //#endregion
176
- //#region src/cli/commands/registry/utils.ts
177
- /**
178
- * Would writing this encoded_dir replace a working one with a broken one?
179
- *
180
- * `encodeDir` is lossy: `~` and emoji do not survive it, so a value derived
181
- * from a project's path can name a folder Claude Code never created. Every
182
- * caller here derives its value that way, and this function is the single
183
- * point they all pass through.
184
- *
185
- * `pai registry reconnect` repairs those by reading the cwd recorded inside the
186
- * transcripts. Without this check the next scan silently reverts the repair —
187
- * and it reverts it in the most confusing possible way, because a read taken
188
- * straight after the repair still shows it. Observed twice on 2026-08-02:
189
- * three projects reconnected, verified, and rediscovered as broken within the
190
- * hour, with the repair command reporting success both times.
191
- *
192
- * Permits the write when the new value resolves to transcripts, or when the
193
- * existing one resolves to nothing — i.e. whenever it cannot make things worse.
194
- */
195
- function worthWriting(db, projectId, encodedDir) {
196
- if (transcriptFiles(join(claudeProjectsDir(), encodedDir)).length > 0) return true;
197
- const current = db.prepare("SELECT encoded_dir FROM projects WHERE id = ?").get(projectId)?.encoded_dir;
198
- if (!current) return true;
199
- return transcriptFiles(join(claudeProjectsDir(), current)).length === 0;
200
- }
201
- /**
202
- * Upsert a project row. Returns { id, isNew }.
203
- *
204
- * Matching priority:
205
- * 1. root_path — most reliable; handles slug collisions
206
- * 2. encoded_dir — Claude project dirs are canonical
207
- * 3. Insert with suffix-deduplication on slug collision
208
- *
209
- * display_name is set to basename(rootPath) on INSERT so that the unified
210
- * listing always shows a human-readable name rather than the kebab-case slug.
211
- */
212
- function upsertProject(db, slug, rootPath, encodedDir) {
213
- const ts = now();
214
- const byPath = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
215
- if (byPath) {
216
- const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
217
- if ((!encodedOwner || encodedOwner.id === byPath.id) && worthWriting(db, byPath.id, encodedDir)) db.prepare("UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?").run(encodedDir, ts, byPath.id);
218
- return {
219
- id: byPath.id,
220
- isNew: false
221
- };
222
- }
223
- const byEncoded = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir);
224
- if (byEncoded) {
225
- const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
226
- if (!pathOwner || pathOwner.id === byEncoded.id) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(rootPath, ts, byEncoded.id);
227
- return {
228
- id: byEncoded.id,
229
- isNew: false
230
- };
231
- }
232
- let finalSlug = slug;
233
- let attempt = 0;
234
- while (true) {
235
- if (!db.prepare("SELECT id FROM projects WHERE slug = ?").get(finalSlug)) break;
236
- attempt++;
237
- finalSlug = `${slug}-${attempt}`;
238
- }
239
- const displayName = basename(rootPath) || finalSlug;
240
- const result = db.prepare(`INSERT OR IGNORE INTO projects
241
- (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)
242
- VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`).run(finalSlug, displayName, rootPath, encodedDir, ts, ts);
243
- if (result.changes === 0) {
244
- const fallback = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(encodedDir) ?? db.prepare("SELECT id FROM projects WHERE root_path = ?").get(rootPath);
245
- if (fallback) return {
246
- id: fallback.id,
247
- isNew: false
248
- };
249
- throw new Error(`upsertProject: INSERT OR IGNORE was suppressed but no matching row found for root_path=${rootPath} encoded_dir=${encodedDir}`);
250
- }
251
- return {
252
- id: result.lastInsertRowid,
253
- isNew: true
254
- };
255
- }
256
- /** Upsert a session note. Returns true if newly inserted. */
257
- function upsertSession(db, projectId, number, date, slug, title, filename) {
258
- if (db.prepare("SELECT id FROM sessions WHERE project_id = ? AND number = ?").get(projectId, number)) return false;
259
- const ts = now();
260
- db.prepare(`INSERT INTO sessions
261
- (project_id, number, date, slug, title, filename, status, created_at)
262
- VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`).run(projectId, number, date, slug, title, filename, ts);
263
- return true;
264
- }
265
-
266
- //#endregion
267
- //#region src/cli/commands/registry/scan.ts
268
- /** Registry scan command: walk ~/.claude/projects/ and populate the registry. */
269
- /**
270
- * Build a reverse map from encoded-dir → real path using the clc session
271
- * registry (~/.claude/session.json).
272
- *
273
- * The clc registry stores { sessions: [{ directory, ... }, ...] }. For each
274
- * entry with a resolvable directory, we encode the realpath and add it to
275
- * the map. This lets the scanner recover project paths that are not in
276
- * Claude's session-registry.json but were registered by clc.
277
- */
278
- function buildClcDirMap() {
279
- const map = /* @__PURE__ */ new Map();
280
- const sessionFile = join(homedir(), ".claude", "session.json");
281
- if (!existsSync(sessionFile)) return map;
282
- try {
283
- const raw = readFileSync(sessionFile, "utf8");
284
- const parsed = JSON.parse(raw);
285
- for (const entry of parsed.sessions ?? []) {
286
- const dir = entry.directory;
287
- if (!dir) continue;
288
- try {
289
- const real = realpathSync(dir);
290
- if (!existsSync(real)) continue;
291
- const encoded = encodeDir(real);
292
- map.set(encoded, real);
293
- } catch {}
294
- }
295
- } catch {}
296
- return map;
297
- }
298
- const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
299
- const PAI_CONFIG_DIR = join(homedir(), ".pai");
300
- const PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, "config.json");
301
- function loadScanConfig() {
302
- if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };
303
- try {
304
- return JSON.parse(readFileSync(PAI_CONFIG_FILE, "utf8"));
305
- } catch {
306
- return { scan_dirs: [] };
307
- }
308
- }
309
- function saveScanConfig(config) {
310
- mkdirSync(PAI_CONFIG_DIR, { recursive: true });
311
- writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", "utf8");
312
- }
313
- /**
314
- * Resolve a path to its canonical form, falling back to the input.
315
- *
316
- * Every path that becomes a `projects.root_path` must go through this. That
317
- * column is UNIQUE, so two spellings of one directory create two projects and
318
- * split session history between them. The spellings are not hypothetical: a
319
- * configured scan_dir of `~/dev/ai` (where `~/dev` is a symlink) makes every
320
- * project under it register under the symlinked path, while a session started
321
- * from the resolved path registers a rival row.
322
- */
323
- function canonicalPath(p) {
324
- try {
325
- return realpathSync(p);
326
- } catch {
327
- return p;
328
- }
329
- }
330
- function resolveHome(p) {
331
- if (p.startsWith("~/")) return join(homedir(), p.slice(2));
332
- return resolve(p);
333
- }
334
- /**
335
- * Recursively find all .md files in a directory, including YYYY/MM subdirectories.
336
- * Returns filenames (basename only).
337
- */
338
- function findNoteFiles(dir) {
339
- const results = [];
340
- if (!existsSync(dir)) return results;
341
- for (const entry of readdirSync(dir, { withFileTypes: true })) if (entry.isFile() && entry.name.endsWith(".md")) results.push(entry.name);
342
- else if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
343
- const yearDir = join(dir, entry.name);
344
- for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) if (monthEntry.isDirectory() && /^\d{2}$/.test(monthEntry.name)) {
345
- const monthDir = join(yearDir, monthEntry.name);
346
- for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) if (noteEntry.isFile() && noteEntry.name.endsWith(".md")) results.push(noteEntry.name);
347
- }
348
- }
349
- return results;
350
- }
351
- function performScan(db) {
352
- const result = {
353
- projectsScanned: 0,
354
- projectsNew: 0,
355
- projectsUpdated: 0,
356
- sessionsScanned: 0,
357
- sessionsNew: 0,
358
- skipped: []
359
- };
360
- if (!existsSync(CLAUDE_PROJECTS_DIR)) throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);
361
- const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {
362
- return statSync(join(CLAUDE_PROJECTS_DIR, name)).isDirectory();
363
- });
364
- const lookupMap = buildEncodedDirMap();
365
- const clcMap = buildClcDirMap();
366
- for (const encodedDir of entries) {
367
- let rootPath = decodeEncodedDir(encodedDir, lookupMap);
368
- if (!existsSync(rootPath) && clcMap.has(encodedDir)) {
369
- const clcPath = clcMap.get(encodedDir);
370
- if (existsSync(clcPath)) rootPath = clcPath;
371
- }
372
- if (!existsSync(rootPath)) {
373
- result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);
374
- result.projectsScanned++;
375
- continue;
376
- }
377
- rootPath = canonicalPath(rootPath);
378
- const slug = slugify(basename(rootPath) || encodedDir);
379
- const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);
380
- result.projectsScanned++;
381
- if (isNew) result.projectsNew++;
382
- else result.projectsUpdated++;
383
- try {
384
- ensurePaiMarker(rootPath, slug);
385
- } catch {}
386
- const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, "Notes");
387
- if (existsSync(claudeNotesDir)) {
388
- if (claudeNotesDir !== join(rootPath, "Notes")) db.prepare("UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?").run(claudeNotesDir, Date.now(), id);
389
- }
390
- if (!existsSync(claudeNotesDir)) continue;
391
- const noteFiles = findNoteFiles(claudeNotesDir);
392
- for (const filename of noteFiles) {
393
- const parsed = parseSessionFilename(filename);
394
- if (!parsed) continue;
395
- result.sessionsScanned++;
396
- if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
397
- }
398
- }
399
- {
400
- const activeProjects = db.prepare("SELECT id, slug, root_path FROM projects WHERE status = 'active'").all();
401
- for (const project of activeProjects) {
402
- const notesDir = join(project.root_path, "Notes");
403
- if (!existsSync(notesDir)) continue;
404
- let files;
405
- try {
406
- files = findNoteFiles(notesDir);
407
- } catch {
408
- continue;
409
- }
410
- for (const filename of files) {
411
- const parsed = parseSessionFilename(filename);
412
- if (!parsed) continue;
413
- result.sessionsScanned++;
414
- if (upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
415
- }
416
- }
417
- }
418
- const config = loadScanConfig();
419
- if (config.scan_dirs.length) for (const rawDir of config.scan_dirs) {
420
- const scanDir = resolveHome(rawDir);
421
- if (!existsSync(scanDir)) {
422
- result.skipped.push(`${rawDir} (configured scan_dir not found)`);
423
- continue;
424
- }
425
- const children = readdirSync(scanDir).filter((name) => {
426
- if (name.startsWith(".")) return false;
427
- const full = join(scanDir, name);
428
- try {
429
- return statSync(full).isDirectory();
430
- } catch {
431
- return false;
432
- }
433
- });
434
- for (const child of children) {
435
- const childPath = canonicalPath(join(scanDir, child));
436
- const childSlug = slugify(child);
437
- const childEncoded = encodeDir(childPath);
438
- const existing = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(childPath);
439
- if (existing) {
440
- result.projectsScanned++;
441
- result.projectsUpdated++;
442
- try {
443
- ensurePaiMarker(childPath, childSlug);
444
- } catch {}
445
- const notesDir = join(childPath, "Notes");
446
- if (existsSync(notesDir)) {
447
- const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
448
- for (const filename of noteFiles) {
449
- const parsed = parseSessionFilename(filename);
450
- if (!parsed) continue;
451
- result.sessionsScanned++;
452
- if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
453
- }
454
- }
455
- continue;
456
- }
457
- const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);
458
- result.projectsScanned++;
459
- if (isNew) result.projectsNew++;
460
- else result.projectsUpdated++;
461
- try {
462
- ensurePaiMarker(childPath, childSlug);
463
- } catch {}
464
- const notesDir = join(childPath, "Notes");
465
- if (existsSync(notesDir)) {
466
- const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(".md"));
467
- for (const filename of noteFiles) {
468
- const parsed = parseSessionFilename(filename);
469
- if (!parsed) continue;
470
- result.sessionsScanned++;
471
- if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) result.sessionsNew++;
472
- }
473
- }
474
- }
475
- }
476
- if (config.scan_dirs.length) {
477
- const markers = discoverPaiMarkers(config.scan_dirs.map(resolveHome).filter(existsSync));
478
- for (const marker of markers) {
479
- const registeredRow = db.prepare("SELECT id, root_path, slug, encoded_dir FROM projects WHERE slug = ?").get(marker.slug);
480
- if (!registeredRow) continue;
481
- const markerRoot = canonicalPath(marker.projectRoot);
482
- if (registeredRow.root_path !== markerRoot) {
483
- const newEncoded = encodeDir(markerRoot);
484
- const now4 = Date.now();
485
- const encodedOwner = db.prepare("SELECT id FROM projects WHERE encoded_dir = ?").get(newEncoded);
486
- const pathOwner = db.prepare("SELECT id FROM projects WHERE root_path = ?").get(markerRoot);
487
- const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;
488
- const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;
489
- const derivedResolves = transcriptFiles(join(claudeProjectsDir(), newEncoded)).length > 0;
490
- const currentResolves = Boolean(registeredRow.encoded_dir) && transcriptFiles(join(claudeProjectsDir(), registeredRow.encoded_dir)).length > 0;
491
- if (encodedSafe && pathSafe && (derivedResolves || !currentResolves)) db.prepare("UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?").run(markerRoot, newEncoded, now4, registeredRow.id);
492
- else if (pathSafe) db.prepare("UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?").run(markerRoot, now4, registeredRow.id);
493
- }
494
- }
495
- }
496
- {
497
- const stale = db.prepare("SELECT id, slug, root_path FROM projects WHERE display_name = slug AND root_path IS NOT NULL AND root_path != ''").all();
498
- for (const row of stale) {
499
- const name = basename(row.root_path);
500
- if (name && name !== row.slug) db.prepare("UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?").run(name, Date.now(), row.id);
501
- }
502
- }
503
- return result;
504
- }
505
- /**
506
- * Run the registry scan CLI command.
507
- *
508
- * @param opts.quick When true, skips verbose output (same scan, less noise).
509
- * The underlying scan is always incremental via upsert —
510
- * this flag exists for hook/daemon-triggered invocations that
511
- * want minimal log output.
512
- */
513
- function cmdScan(db, opts = {}) {
514
- const config = loadScanConfig();
515
- if (!opts.quick) {
516
- console.log(dim("Scanning ~/.claude/projects/ ..."));
517
- if (config.scan_dirs.length) console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(", ")}`));
518
- console.log(dim("Scanning project-root Notes/ directories ..."));
519
- }
520
- let result;
521
- try {
522
- result = performScan(db);
523
- } catch (e) {
524
- console.error(err(String(e)));
525
- process.exitCode = 1;
526
- return;
527
- }
528
- if (!opts.quick) {
529
- console.log(ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`));
530
- console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));
531
- console.log(dim(` Sessions: ${result.sessionsNew} new`));
532
- if (result.skipped.length) {
533
- console.log();
534
- console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));
535
- for (const s of result.skipped.slice(0, 10)) console.log(dim(` ${s}`));
536
- if (result.skipped.length > 10) console.log(dim(` ... and ${result.skipped.length - 10} more`));
537
- }
538
- } else process.stderr.write(`[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions (${result.projectsNew}+${result.sessionsNew} new).\n`);
539
- }
540
-
541
- //#endregion
542
- //#region src/daemon/templates/triple-extraction-prompt.ts
543
- /**
544
- * triple-extraction-prompt.ts — Prompt template for KG triple extraction.
545
- *
546
- * Used by the session-summary-worker to extract structured facts from
547
- * a completed session summary and store them in the temporal knowledge graph.
548
- */
549
- function buildTripleExtractionPrompt(params) {
550
- return `Extract structured entities and relations from this coding session.
551
-
552
- Output a single JSON object with two arrays: "entities" and "relations".
553
-
554
- Entity types: project | person | concept | tool | file | version | decision | technology | organization
555
-
556
- Rules:
557
- - Be SPECIFIC: entity names must be concrete (e.g., "FSRS", "Glidr", "the owner")
558
- - Use snake_case relation verb phrases (e.g., "uses_algorithm", "decided_to", "shipped_version")
559
- - Skip opinions, speculation, and "we should" statements
560
- - Skip entities obvious from project metadata unless they have a meaningful relation
561
- - Maximum 15 relations per session — pick the most important
562
- - Each entity should have a brief description (1 sentence, what it is in this context)
563
- - Each relation must reference entity names that appear in the entities array
564
-
565
- Example output:
566
- {
567
- "entities": [
568
- {"name": "Glidr", "type": "project", "description": "Flashcard app using FSRS spaced repetition"},
569
- {"name": "FSRS", "type": "concept", "description": "Free Spaced Repetition Scheduler algorithm"},
570
- {"name": "the owner", "type": "person", "description": "Developer of Glidr and Quassl"},
571
- {"name": "Quassl", "type": "project", "description": "iOS app being rewritten in Flutter"},
572
- {"name": "Flutter", "type": "technology", "description": "Cross-platform mobile framework"}
573
- ],
574
- "relations": [
575
- {"source": "Glidr", "relation": "uses_algorithm", "target": "FSRS"},
576
- {"source": "Glidr", "relation": "shipped_version", "target": "1.0.5"},
577
- {"source": "the owner", "relation": "decided_to_rewrite", "target": "Quassl"},
578
- {"source": "Quassl", "relation": "migrating_to", "target": "Flutter"}
579
- ]
580
- }
581
-
582
- PROJECT: ${params.projectSlug}
583
-
584
- SESSION CONTENT:
585
- ${params.sessionContent}
586
-
587
- GIT COMMITS:
588
- ${params.gitLog}
589
-
590
- JSON object (entities + relations):`;
591
- }
592
-
593
- //#endregion
594
- //#region src/workers/daemon-llm.ts
595
- /**
596
- * daemon-llm.ts — provider-routed LLM spawns for daemon-side background calls.
597
- *
598
- * Session summaries, context handovers and KG extraction used to spawn a bare
599
- * `claude --model <tier>` with the ambient environment: on a machine whose
600
- * only login is a worker provider that spawn either dies (no Anthropic
601
- * credentials) or bills Anthropic outside the provider abstraction. This
602
- * module routes those spawns through the same registry and env build the
603
- * worker runner uses (`buildRunEnv`), resolving the tier alias to the
604
- * provider's concrete model id. With no usable provider configured it falls
605
- * back to the historical behaviour: the tier alias itself, ambient env minus
606
- * the Anthropic API key.
607
- */
608
- /** Timeout per tier (ms) — a class-appropriate budget, not a per-model one. */
609
- const LLM_TIMEOUT_MS = {
610
- haiku: 6e4,
611
- sonnet: 12e4,
612
- opus: 3e5
613
- };
614
- /** Resolve a tier to the provider's concrete model id: the fast capability
615
- * for the cheap tier, the default model for the middle and top tiers. */
616
- function tierModel(provider, tier) {
617
- return resolveModelCapability(provider, tier === "haiku" ? "fast" : "default");
618
- }
619
- /**
620
- * Containment every daemon-side LLM spawn carries. These calls are
621
- * text-in/text-out: the prompt carries the context, the answer is the
622
- * product, and nothing about summarizing a session justifies a tool. An
623
- * unrestricted background summarizer was observed on 2026-09-18 (01:33-01:51)
624
- * rewriting the live config with a schema-shaped dump while the daemon ran;
625
- * with the tool grant empty and MCP strictly empty, that class of damage is
626
- * structurally impossible rather than merely unlikely.
627
- */
628
- function containmentArgs(logDir) {
629
- return [
630
- "--strict-mcp-config",
631
- "--mcp-config",
632
- ensureNoMcpConfig(logDir),
633
- "--allowedTools",
634
- ""
635
- ];
636
- }
637
- /** The historical fallback: tier alias as the model id, ambient env with the
638
- * Anthropic API key stripped (so the CLI uses its interactive login). */
639
- function legacyPlan(tier, config) {
640
- const { ANTHROPIC_API_KEY: _drop, ...env } = process.env;
641
- return {
642
- model: tier,
643
- args: [
644
- "--model",
645
- tier,
646
- ...containmentArgs(workersLogDir(config ?? defaultWorkersConfig())),
647
- "-p",
648
- "--no-session-persistence"
649
- ],
650
- env,
651
- timeoutMs: LLM_TIMEOUT_MS[tier],
652
- provider: null
653
- };
654
- }
655
- /**
656
- * Plan a daemon-side LLM spawn for a tier. Provider resolution failures (no
657
- * config, workers off, no usable provider) fall back to the tier-alias path;
658
- * a configured-but-broken provider (unreadable key file) throws, because
659
- * silently degrading to Anthropic billing is worse than a failed summary.
660
- */
661
- async function planLlmSpawn(tier, configPath) {
662
- let provider = null;
663
- let providerName = null;
664
- let config = null;
665
- try {
666
- const { workers } = readWorkersSection(configPath);
667
- config = workers;
668
- if (workers.enabled) {
669
- const target = resolveTarget(workers, workersLogDir(workers), {});
670
- provider = target.provider;
671
- providerName = target.providerName;
672
- }
673
- } catch {
674
- provider = null;
675
- providerName = null;
676
- }
677
- if (!provider || !providerName || !config) return legacyPlan(tier, config);
678
- const logDir = workersLogDir(config);
679
- let proxyUrl;
680
- if (provider.protocol === "openai") {
681
- mkdirSync(logDir, { recursive: true });
682
- proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${providerName}`;
683
- }
684
- const model = tierModel(provider, tier);
685
- return {
686
- model,
687
- args: [
688
- "--model",
689
- model,
690
- ...containmentArgs(logDir),
691
- "-p",
692
- "--no-session-persistence"
693
- ],
694
- env: buildRunEnv(provider, true, proxyUrl),
695
- timeoutMs: LLM_TIMEOUT_MS[tier],
696
- provider: providerName
697
- };
698
- }
699
-
700
- //#endregion
701
- //#region src/memory/kg-extraction.ts
702
- /**
703
- * kg-extraction.ts — Shared KG triple extraction logic.
704
- *
705
- * Extracted from session-summary-worker.ts so both the worker and the
706
- * CLI backfill (`pai kg backfill`) can use the same code path.
707
- *
708
- * Provides:
709
- * - findClaudeBinary() — locate the claude CLI
710
- * - spawnClaude() — generic prompt -> response runner (provider-routed)
711
- * - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres
712
- */
713
- /**
714
- * Find the `claude` CLI binary. Checks common installation locations first
715
- * (launchd PATH is minimal so bare "claude" often won't resolve).
716
- */
717
- function findClaudeBinary() {
718
- const candidates = [
719
- join(homedir(), ".local", "bin", "claude"),
720
- join(homedir(), ".claude", "local", "claude"),
721
- "/usr/local/bin/claude",
722
- "/opt/homebrew/bin/claude"
723
- ];
724
- for (const candidate of candidates) try {
725
- if (existsSync(candidate)) return candidate;
726
- } catch {}
727
- return "claude";
728
- }
729
- /**
730
- * Spawn the claude CLI with a prompt on stdin and return stdout.
731
- *
732
- * Routed through the provider registry (planLlmSpawn): the tier resolves to
733
- * the configured provider's model id and the env carries its base URL and
734
- * token. With no provider configured it degrades to the historical behaviour
735
- * — the tier alias itself, ambient env minus ANTHROPIC_API_KEY.
736
- */
737
- async function spawnClaude(prompt, tier = "sonnet") {
738
- const claudeBin = findClaudeBinary();
739
- if (!claudeBin) {
740
- process.stderr.write("[kg-extraction] claude CLI not found.\n");
741
- return null;
742
- }
743
- let plan;
744
- try {
745
- plan = await planLlmSpawn(tier);
746
- } catch (e) {
747
- process.stderr.write(`[kg-extraction] could not plan ${tier} spawn: ${e}\n`);
748
- return null;
749
- }
750
- const { spawn } = await import("node:child_process");
751
- return new Promise((resolve) => {
752
- let timer = null;
753
- const child = spawn(claudeBin, plan.args, {
754
- env: plan.env,
755
- stdio: [
756
- "pipe",
757
- "pipe",
758
- "pipe"
759
- ]
760
- });
761
- let stdout = "";
762
- let stderr = "";
763
- child.stdout.on("data", (chunk) => {
764
- stdout += chunk.toString();
765
- });
766
- child.stderr.on("data", (chunk) => {
767
- stderr += chunk.toString();
768
- });
769
- child.on("error", (err) => {
770
- if (timer) {
771
- clearTimeout(timer);
772
- timer = null;
773
- }
774
- process.stderr.write(`[kg-extraction] ${plan.model} spawn error: ${err.message}\n`);
775
- resolve(null);
776
- });
777
- child.on("close", (code) => {
778
- if (timer) {
779
- clearTimeout(timer);
780
- timer = null;
781
- }
782
- if (code !== 0) {
783
- process.stderr.write(`[kg-extraction] ${plan.model} exited ${code}: ${stderr.slice(0, 300)}\n`);
784
- resolve(null);
785
- } else resolve(stdout.trim() || null);
786
- });
787
- timer = setTimeout(() => {
788
- process.stderr.write(`[kg-extraction] ${plan.model} timed out — killing process.\n`);
789
- child.kill("SIGTERM");
790
- resolve(null);
791
- }, plan.timeoutMs);
792
- child.stdin.write(prompt);
793
- child.stdin.end();
794
- });
795
- }
796
- /**
797
- * Extract structured KG triples from a session summary and store them in
798
- * Postgres. Idempotent: if a (subject, predicate) pair already has the same
799
- * object, no new row is added; if the object differs, the old triple is
800
- * invalidated (valid_to = NOW()) and a new one is inserted.
801
- *
802
- * Best-effort: per-triple errors are caught and logged but never thrown.
803
- * Returns a small stats object so callers can report progress.
804
- */
805
- async function extractAndStoreTriples(pool, params) {
806
- const stats = {
807
- extracted: 0,
808
- added: 0,
809
- superseded: 0
810
- };
811
- const jsonOutput = await spawnClaude(buildTripleExtractionPrompt({
812
- sessionContent: params.summaryText,
813
- projectSlug: params.projectSlug,
814
- gitLog: params.gitLog ?? ""
815
- }), params.model ?? "sonnet");
816
- if (!jsonOutput) return stats;
817
- const cleaned = jsonOutput.replace(/^```json\s*/m, "").replace(/^```\s*/m, "").replace(/\s*```$/m, "").trim();
818
- let triples;
819
- try {
820
- const parsed = JSON.parse(cleaned);
821
- if (Array.isArray(parsed)) triples = parsed;
822
- else if (parsed && typeof parsed === "object" && Array.isArray(parsed.relations)) {
823
- const newFmt = parsed;
824
- if (params.federationDb && Array.isArray(newFmt.entities)) {
825
- const tenantId = params.tenantId ?? "default";
826
- for (const entity of newFmt.entities) {
827
- if (!entity.name) continue;
828
- try {
829
- upsertKgEntity(params.federationDb, {
830
- name: entity.name,
831
- type: entity.type ?? "unknown",
832
- description: entity.description,
833
- tenantId
834
- });
835
- } catch (entityErr) {
836
- process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\n`);
837
- }
838
- }
839
- }
840
- triples = newFmt.relations.map((r) => ({
841
- subject: r.source,
842
- predicate: r.relation,
843
- object: r.target
844
- }));
845
- } else {
846
- process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\n`);
847
- return stats;
848
- }
849
- } catch (e) {
850
- process.stderr.write(`[kg-extraction] JSON parse failed: ${e}\n`);
851
- return stats;
852
- }
853
- if (!Array.isArray(triples)) return stats;
854
- stats.extracted = triples.length;
855
- for (const t of triples) {
856
- if (!t.subject || !t.predicate || !t.object) continue;
857
- try {
858
- const existing = await kgQuery(pool, {
859
- subject: t.subject,
860
- predicate: t.predicate,
861
- project_id: params.projectId ?? void 0
862
- });
863
- if (existing.find((e) => e.object === t.object && !e.valid_to)) continue;
864
- const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);
865
- if (supersedes) {
866
- await kgInvalidate(pool, supersedes.id);
867
- stats.superseded++;
868
- }
869
- await kgAdd(pool, {
870
- subject: t.subject,
871
- predicate: t.predicate,
872
- object: t.object,
873
- project_id: params.projectId ?? void 0,
874
- source_session: params.sessionId,
875
- confidence: "EXTRACTED"
876
- });
877
- stats.added++;
878
- } catch (tripleErr) {
879
- process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\n`);
880
- }
881
- }
882
- return stats;
883
- }
884
-
885
- //#endregion
886
- //#region src/session/checkpoint-block.ts
887
- /**
888
- * Shared "## Continue" checkpoint logic for a project's TODO.md.
889
- *
890
- * WHY THIS MODULE EXISTS
891
- * ----------------------
892
- * Before this, `pause.ts` and `handover.ts` each carried their own copy of
893
- * findProjectTodo / stripContinueSection / block-builder. Both wrote the same
894
- * fixed four-line block, and both stripped any existing ## Continue section
895
- * unconditionally. The consequence was that a rich, model-authored checkpoint
896
- * could never survive:
897
- *
898
- * 1. `pai pause` had no way to accept a body, so the model printed its
899
- * checkpoint to the terminal and it was lost.
900
- * 2. Even if a body had been written by hand, the session-stop hook runs
901
- * `pai session handover` on every clean exit, which regenerated the
902
- * generic block and erased it.
903
- *
904
- * So there are two jobs here:
905
- *
906
- * - AUTHORED writes (`pai pause --body-file`) carry the model's markdown
907
- * verbatim, wrapped in explicit start/end markers.
908
- * - AUTO writes (hooks) must never destroy an authored checkpoint belonging
909
- * to the *same* session. They may replace a stale one left by an earlier
910
- * session, otherwise TODO.md would show a checkpoint that no longer
911
- * describes where the work stands.
912
- *
913
- * PARSING
914
- * -------
915
- * A rich body can legitimately contain `---` rules and `##` headings, which the
916
- * old heuristic scanner treated as section terminators. Authored blocks are
917
- * therefore delimited by an explicit HTML-comment pair; the legacy heuristic is
918
- * kept only as a fallback for blocks written before this change.
919
- */
920
- /** Locations searched for a project TODO.md, in priority order. */
921
- const TODO_LOCATIONS = [
922
- "Notes/TODO.md",
923
- ".claude/Notes/TODO.md",
924
- "tasks/todo.md",
925
- "TODO.md"
926
- ];
927
- const MARKER_OPEN = "<!-- pai:checkpoint";
928
- const MARKER_CLOSE = "<!-- /pai:checkpoint -->";
929
- /**
930
- * Markers for a handover that has been superseded but not thrown away.
931
- *
932
- * Deliberately NOT `pai:checkpoint`: `locateContinue` scans for that marker and
933
- * for the `## Continue` heading, so an archived block written verbatim would be
934
- * found as the live section and rewritten in place of it. Archived entries are
935
- * inert by construction — different marker, no heading.
936
- */
937
- const ARCHIVE_OPEN = "<!-- pai:archived-handover";
938
- const ARCHIVE_CLOSE = "<!-- /pai:archived-handover -->";
939
- const ARCHIVE_HEADING = "## Previous handovers";
940
- /**
941
- * How many superseded handovers TODO.md keeps.
942
- *
943
- * Enough that a handover survives a run of sessions that each end without
944
- * pausing, which is the sequence that destroyed one on 2026-08-04. Not
945
- * unbounded: TODO.md is read by a human at the top of every session, and a file
946
- * that grows without limit stops being read, which is its own kind of loss.
947
- */
948
- const ARCHIVE_LIMIT = 5;
949
- const CONTINUE_HEADING = "## Continue";
950
- function findProjectTodo(rootPath) {
951
- for (const rel of TODO_LOCATIONS) {
952
- const full = join(rootPath, rel);
953
- if (existsSync(full)) try {
954
- return {
955
- path: full,
956
- content: readFileSync(full, "utf8")
957
- };
958
- } catch {}
959
- }
960
- return null;
961
- }
962
- /**
963
- * Resolve the TODO.md to write to, creating Notes/ if nothing exists yet.
964
- * Returns null only when the directory could not be created.
965
- */
966
- function resolveTodoTarget(rootPath, opts = {}) {
967
- const found = findProjectTodo(rootPath);
968
- if (found) return found;
969
- const notesDir = join(rootPath, "Notes");
970
- if (opts.create !== false) try {
971
- if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });
972
- } catch {
973
- return null;
974
- }
975
- return {
976
- path: join(notesDir, "TODO.md"),
977
- content: ""
978
- };
979
- }
980
- /**
981
- * Parse a `<!-- pai:checkpoint key="value" ... -->` marker line.
982
- * Returns null when the line is not a marker.
983
- */
984
- function parseMarker(line) {
985
- const trimmed = line.trim();
986
- if (!trimmed.startsWith(MARKER_OPEN)) return null;
987
- const attrs = {};
988
- for (const m of trimmed.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) attrs[m[1]] = m[2];
989
- return {
990
- authored: attrs.authored === "model" ? "model" : "auto",
991
- session: attrs.session || void 0,
992
- sessionId: attrs["session-id"] || void 0,
993
- ts: attrs.ts || void 0
994
- };
995
- }
996
- /**
997
- * Lines an auto-generated block is made of. Anything else in a section is
998
- * content somebody put there deliberately.
999
- *
1000
- * The blockquote marker is optional on every pattern. These lines appear
1001
- * quoted when the block builder emits them as its header, and unquoted when a
1002
- * caller passes the same text as a BODY — which the session-stop hook does: it
1003
- * builds `Working directory: ${cwd}` and hands it over as state. Matching only
1004
- * the quoted form made such a body read as content, which is how a session
1005
- * that did no work at all overwrote a real handover on 2026-08-04. A line that
1006
- * merely restates the generated header is boilerplate wherever it sits.
1007
- */
1008
- const BOILERPLATE_PATTERNS = [
1009
- /^##\s+Continue$/,
1010
- /^<!--\s*\/?pai:checkpoint/,
1011
- /^>?\s*\*\*Last session:\*\*/,
1012
- /^>?\s*\*\*Paused at:\*\*/,
1013
- /^>?\s*Working directory:/,
1014
- /^>?\s*Resume with:/,
1015
- /^>?\s*_No checkpoint body was recorded/,
1016
- /^-{3,}$/
1017
- ];
1018
- /** A blank line, with or without a blockquote marker. */
1019
- const BLANK_LINE = /^>?\s*$/;
1020
- /**
1021
- * True when a section contains nothing but generated header lines.
1022
- *
1023
- * This is the guard that stops an auto write from destroying content it did
1024
- * not author. A session that hit the old clobbering bug may have worked around
1025
- * it by hand — writing its state into a subsection underneath the generated
1026
- * header lines, in an unmarked block. Those blocks predate the marker, so
1027
- * authorship cannot be read off them; the only safe signal is whether anything
1028
- * beyond boilerplate is present.
1029
- */
1030
- function isBoilerplateOnly(lines) {
1031
- return lines.every((line) => {
1032
- const t = line.trim();
1033
- return BLANK_LINE.test(t) || BOILERPLATE_PATTERNS.some((re) => re.test(t));
1034
- });
1035
- }
1036
- /**
1037
- * True when a checkpoint body says something the generated header does not.
1038
- *
1039
- * "Has a body" and "has something to say" are not the same question, and the
1040
- * preservation rules care only about the second. An unattended writer that
1041
- * emits a single line restating the working directory has produced a body by
1042
- * any string test and a handover by none.
1043
- */
1044
- function hasSubstance(body) {
1045
- const text = (body ?? "").trim();
1046
- if (!text) return false;
1047
- return !isBoilerplateOnly(text.split("\n"));
1048
- }
1049
- /**
1050
- * Extract the non-boilerplate content of a section, or "" if there is none.
1051
- *
1052
- * Interior blank lines are kept. They are not decoration — in Markdown they
1053
- * are what separates a paragraph from the table or list that follows, so
1054
- * dropping them (as this did while it treated every blank line as boilerplate)
1055
- * silently welds a checkpoint body into one unreadable run.
1056
- */
1057
- function extractSectionContent(lines) {
1058
- const kept = [];
1059
- for (const line of lines) {
1060
- const t = line.trim();
1061
- if (BOILERPLATE_PATTERNS.some((re) => re.test(t))) continue;
1062
- kept.push(line);
1063
- }
1064
- return trimBlankEdges(collapseBlankRuns(kept)).join("\n");
1065
- }
1066
- /** Drop leading and trailing blank lines, leaving interior spacing alone. */
1067
- function trimBlankEdges(lines) {
1068
- let start = 0;
1069
- let end = lines.length;
1070
- while (start < end && BLANK_LINE.test(lines[start].trim())) start += 1;
1071
- while (end > start && BLANK_LINE.test(lines[end - 1].trim())) end -= 1;
1072
- return lines.slice(start, end);
1073
- }
1074
- /**
1075
- * Collapse runs of blank lines to a single blank.
1076
- *
1077
- * Removing a boilerplate line leaves the blank that surrounded it behind, so
1078
- * stripping the header can open a three-line gap in the middle of the body.
1079
- * One blank line is all Markdown needs.
1080
- */
1081
- function collapseBlankRuns(lines) {
1082
- const out = [];
1083
- let lastWasBlank = false;
1084
- for (const line of lines) {
1085
- const isBlank = BLANK_LINE.test(line.trim());
1086
- if (isBlank && lastWasBlank) continue;
1087
- out.push(line);
1088
- lastWasBlank = isBlank;
1089
- }
1090
- return out;
1091
- }
1092
- /**
1093
- * Locate the existing ## Continue section.
1094
- *
1095
- * When the section carries an explicit marker pair, the close marker defines
1096
- * the end — this is what lets a rich body contain `---` and `##` safely.
1097
- * Otherwise the legacy heuristic applies: stop at the first `---` or the next
1098
- * `##` heading.
1099
- */
1100
- function locateContinue(content) {
1101
- const lines = content.split("\n");
1102
- const startIdx = lines.findIndex((l) => l.trim() === CONTINUE_HEADING);
1103
- if (startIdx === -1) return null;
1104
- let meta = null;
1105
- let markerIdx = -1;
1106
- for (let i = startIdx + 1; i < Math.min(startIdx + 6, lines.length); i++) {
1107
- const parsed = parseMarker(lines[i]);
1108
- if (parsed) {
1109
- meta = parsed;
1110
- markerIdx = i;
1111
- break;
1112
- }
1113
- if (lines[i].trim() !== "") break;
1114
- }
1115
- let endIdx = lines.length;
1116
- if (markerIdx !== -1) {
1117
- const closeIdx = lines.findIndex((l, i) => i > markerIdx && l.trim() === MARKER_CLOSE);
1118
- if (closeIdx !== -1) endIdx = closeIdx + 1;
1119
- else endIdx = heuristicEnd(lines, startIdx);
1120
- } else endIdx = heuristicEnd(lines, startIdx);
1121
- let trailingEnd = endIdx;
1122
- while (trailingEnd < lines.length && lines[trailingEnd].trim() === "") trailingEnd += 1;
1123
- if (trailingEnd < lines.length && lines[trailingEnd].trim() === "---") trailingEnd += 1;
1124
- else trailingEnd = endIdx;
1125
- return {
1126
- startIdx,
1127
- endIdx: trailingEnd,
1128
- meta,
1129
- lines: lines.slice(startIdx, trailingEnd)
1130
- };
1131
- }
1132
- /**
1133
- * Legacy scanner for blocks written before checkpoint markers existed.
1134
- *
1135
- * Terminates on a horizontal rule or the next level-1 or level-2 heading. Note
1136
- * the `(?!#)` — `###` is a *subsection* of `## Continue`, not a terminator. The
1137
- * original scanner stopped at any run of `#`, which meant a `### Restored
1138
- * state` subsection fell outside the section entirely and could not be seen,
1139
- * let alone carried forward.
1140
- *
1141
- * `#{1,2}` rather than `##`, because matching only `##` did not stop at an H1
1142
- * and therefore ATE IT. A TODO.md whose `## Continue` block precedes its
1143
- * `# TODO` title had the title absorbed into the section and destroyed on the
1144
- * next regenerate — reported independently by three sessions on 2026-08-03,
1145
- * one of which lost it three times and recovered from git each time.
1146
- *
1147
- * An H1 is a document-level heading. It can never be part of a `## Continue`
1148
- * section, so treating it as a terminator is not a heuristic improvement but a
1149
- * structural fact.
1150
- */
1151
- function heuristicEnd(lines, startIdx) {
1152
- for (let i = startIdx + 1; i < lines.length; i++) {
1153
- const trimmed = lines[i].trim();
1154
- if (trimmed === "---" || /^#{1,2}(?!#)/.test(trimmed) && trimmed !== CONTINUE_HEADING) return i;
1155
- }
1156
- return lines.length;
1157
- }
1158
- /** Remove the ## Continue section, returning the remainder of the document. */
1159
- function stripContinue(content) {
1160
- const found = locateContinue(content);
1161
- if (!found) return content;
1162
- const lines = content.split("\n");
1163
- const before = lines.slice(0, found.startIdx);
1164
- const after = lines.slice(found.endIdx);
1165
- while (after.length > 0 && after[0].trim() === "") after.shift();
1166
- return [...before, ...after].join("\n");
1167
- }
1168
- function escapeAttr(value) {
1169
- return value.replace(/"/g, "'");
1170
- }
1171
- function buildContinueBlock(opts) {
1172
- const ts = opts.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
1173
- const attrs = [
1174
- `authored="${opts.authored}"`,
1175
- `session="${escapeAttr(opts.sessionLine)}"`,
1176
- opts.sessionId ? `session-id="${escapeAttr(opts.sessionId)}"` : null,
1177
- `ts="${ts}"`
1178
- ].filter(Boolean).join(" ");
1179
- const header = [
1180
- `> **Last session:** ${opts.sessionLine}`,
1181
- `> **Paused at:** ${ts}`,
1182
- ">",
1183
- `> Working directory: ${opts.cwd}`
1184
- ];
1185
- if (opts.sessionId) header.push(">", `> Resume with: \`claude --resume ${opts.sessionId}\``);
1186
- const body = (opts.body ?? "").trim();
1187
- const parts = [
1188
- CONTINUE_HEADING,
1189
- "",
1190
- `${MARKER_OPEN} ${attrs} -->`,
1191
- "",
1192
- ...header
1193
- ];
1194
- if (body) parts.push("", body);
1195
- else parts.push(">", "> _No checkpoint body was recorded — see the latest session note._");
1196
- parts.push("", MARKER_CLOSE, "", "---", "");
1197
- return parts.join("\n");
1198
- }
1199
- /**
1200
- * Write the ## Continue block, honouring the preservation rules.
1201
- *
1202
- * An AUTO write is unattended — it fires from the session-stop and pre-compact
1203
- * hooks — so it operates under one governing rule: **never destroy content it
1204
- * did not author.** Three cases follow from that:
1205
- *
1206
- * 1. An authored checkpoint for the SAME session is left untouched. The hooks
1207
- * fire after the model has already recorded the real state; overwriting it
1208
- * with metadata is the bug this module exists to fix.
1209
- *
1210
- * "Same session" is decided by the Claude session UUID whenever both sides
1211
- * know it, and only falls back to the human-readable session line when one
1212
- * of them does not. The line is derived from the session note filename,
1213
- * and `session-stop.sh` *renames and renumbers that file* — via `session
1214
- * slug --apply` and `session cleanup --execute` — before it reaches the
1215
- * handover step. So the key the hook computes at exit is not the key the
1216
- * model wrote seconds earlier, and a filename-keyed comparison mismatches
1217
- * by construction. Observed live on 2026-08-01: notes renumbered twice
1218
- * within a single session. The UUID is the only identifier that holds
1219
- * still.
1220
- * 2. An authored checkpoint from an EARLIER session is stale — TODO.md would
1221
- * otherwise keep pointing at the wrong session — so it is replaced. Its
1222
- * content is not lost: `pai pause` mirrors every authored body into the
1223
- * session note.
1224
- * 3. An UNMARKED block predates the marker, so authorship cannot be read off
1225
- * it. If it is nothing but generated header lines it is replaced. If it
1226
- * carries anything else, that content was put there deliberately — quite
1227
- * possibly as a hand-rolled workaround for the very clobbering this fixes —
1228
- * and is carried forward into the new block rather than dropped.
1229
- *
1230
- * A MODEL write always replaces: the model is authoring the checkpoint, and a
1231
- * newer one supersedes an older one.
1232
- */
1233
- /**
1234
- * Do an existing checkpoint and an incoming write describe the same session?
1235
- *
1236
- * The UUID is authoritative when both sides carry one: it is assigned by Claude
1237
- * Code and never changes for the life of the session. The session line is a
1238
- * derived, mutable label and is only consulted when there is no UUID to compare
1239
- * — a checkpoint written before `--session-id` was threaded through, or an auto
1240
- * write from a caller that was not given one.
1241
- */
1242
- function isSameSession(meta, opts) {
1243
- if (meta.sessionId && opts.sessionId) return meta.sessionId === opts.sessionId;
1244
- return meta.session === opts.sessionLine;
1245
- }
1246
- /**
1247
- * How long a model-authored checkpoint is protected from automated overwriting.
1248
- *
1249
- * Sized for the race, not for the session: the gap between a model writing its
1250
- * checkpoint and a hook firing is seconds to minutes, and an hour covers even a
1251
- * slow checkpoint on a loaded machine. Anything genuinely from an earlier
1252
- * session is hours or days old and still falls through to case 2, which is what
1253
- * keeps TODO.md from freezing on a checkpoint nobody will ever replace.
1254
- */
1255
- const AUTHORED_GRACE_MS = 3600 * 1e3;
1256
- /** Was this checkpoint written recently enough to still belong to the run in progress? */
1257
- function isRecent(meta, now) {
1258
- if (!meta.ts) return false;
1259
- const then = Date.parse(meta.ts);
1260
- if (Number.isNaN(then)) return false;
1261
- const nowMs = now ? Date.parse(now) : Date.now();
1262
- if (Number.isNaN(nowMs)) return false;
1263
- return nowMs - then < AUTHORED_GRACE_MS;
1264
- }
1265
- /**
1266
- * Turn a live checkpoint block into an inert archive entry.
1267
- *
1268
- * Two things have to go: the `## Continue` heading and the `pai:checkpoint`
1269
- * marker. Both are what `locateContinue` looks for, so leaving either in place
1270
- * would let a later write treat the archive as the live section — and the next
1271
- * one after that would then archive the archive. Everything else is kept
1272
- * verbatim, because the body is the whole reason this exists.
1273
- */
1274
- function toArchiveEntry(lines, meta) {
1275
- const out = [];
1276
- const label = meta?.session ?? "Unknown session";
1277
- const stamp = meta?.ts ? ` — checkpointed ${meta.ts}` : "";
1278
- out.push(`${ARCHIVE_OPEN} session="${label}"${meta?.ts ? ` ts="${meta.ts}"` : ""} -->`);
1279
- out.push("");
1280
- out.push(`### ${label}${stamp}`);
1281
- out.push("");
1282
- for (const line of lines) {
1283
- if (line.trim() === CONTINUE_HEADING) continue;
1284
- if (line.trim().startsWith(MARKER_OPEN)) continue;
1285
- if (line.trim() === MARKER_CLOSE) continue;
1286
- if (line.trim() === "---") continue;
1287
- out.push(line);
1288
- }
1289
- while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
1290
- out.push("");
1291
- out.push(ARCHIVE_CLOSE);
1292
- return out;
1293
- }
1294
- /**
1295
- * Put a superseded handover into the archive section, newest first.
1296
- *
1297
- * `rest` is the document with the Continue section already stripped. The
1298
- * archive lives immediately below it so a reader meets the current handover
1299
- * first and the previous ones directly after, rather than hunting for them at
1300
- * the bottom of a file that also holds the project's open work.
1301
- */
1302
- function archiveInto(rest, entry) {
1303
- const lines = rest.split("\n");
1304
- const headingIdx = lines.findIndex((l) => l.trim() === ARCHIVE_HEADING);
1305
- if (headingIdx === -1) return [
1306
- ARCHIVE_HEADING,
1307
- "",
1308
- ...entry,
1309
- "",
1310
- "---",
1311
- "",
1312
- rest.trimStart()
1313
- ].join("\n");
1314
- const before = lines.slice(0, headingIdx + 1);
1315
- const after = lines.slice(headingIdx + 1);
1316
- while (after.length > 0 && after[0].trim() === "") after.shift();
1317
- const merged = [
1318
- ...entry,
1319
- "",
1320
- ...after
1321
- ];
1322
- const kept = [];
1323
- let seen = 0;
1324
- for (let i = 0; i < merged.length; i++) {
1325
- if (merged[i].trim().startsWith(ARCHIVE_OPEN)) {
1326
- seen += 1;
1327
- if (seen > ARCHIVE_LIMIT) {
1328
- while (i < merged.length && merged[i].trim() !== ARCHIVE_CLOSE) i++;
1329
- continue;
1330
- }
1331
- }
1332
- kept.push(merged[i]);
1333
- }
1334
- return [
1335
- ...before,
1336
- "",
1337
- ...kept
1338
- ].join("\n");
1339
- }
1340
- function applyContinue(opts) {
1341
- const target = resolveTodoTarget(opts.rootPath, { create: !opts.dryRun });
1342
- if (!target) return {
1343
- action: "failed",
1344
- path: null,
1345
- block: buildContinueBlock(opts),
1346
- error: "Could not resolve or create a TODO.md target"
1347
- };
1348
- const existing = locateContinue(target.content);
1349
- let carriedForward = false;
1350
- let effectiveBody = opts.body;
1351
- let archiveEntry = null;
1352
- if (opts.authored === "auto" && existing) {
1353
- if (existing.meta?.authored === "model" && isSameSession(existing.meta, opts)) return {
1354
- action: "preserved",
1355
- path: target.path,
1356
- block: buildContinueBlock(opts),
1357
- preservedMeta: existing.meta
1358
- };
1359
- if (existing.meta && !hasSubstance(opts.body) && !isBoilerplateOnly(existing.lines)) return {
1360
- action: "preserved",
1361
- path: target.path,
1362
- block: buildContinueBlock(opts),
1363
- preservedMeta: existing.meta ?? void 0
1364
- };
1365
- if (existing.meta?.authored === "model" && isRecent(existing.meta, opts.timestamp) && !isBoilerplateOnly(existing.lines)) return {
1366
- action: "preserved",
1367
- path: target.path,
1368
- block: buildContinueBlock(opts),
1369
- preservedMeta: existing.meta
1370
- };
1371
- if (existing.meta?.authored === "model" && !isBoilerplateOnly(existing.lines) && !isSameSession(existing.meta, opts)) archiveEntry = toArchiveEntry(existing.lines, existing.meta);
1372
- if (!existing.meta && !isBoilerplateOnly(existing.lines)) {
1373
- const salvaged = extractSectionContent(existing.lines);
1374
- if (salvaged) {
1375
- effectiveBody = [
1376
- "_Carried forward from the previous checkpoint (author unknown — this",
1377
- "block predates checkpoint authorship markers):_",
1378
- "",
1379
- salvaged
1380
- ].join("\n");
1381
- carriedForward = true;
1382
- }
1383
- }
1384
- }
1385
- const block = buildContinueBlock({
1386
- ...opts,
1387
- body: effectiveBody
1388
- });
1389
- if (opts.dryRun) return {
1390
- action: "written",
1391
- path: target.path,
1392
- block,
1393
- carriedForward,
1394
- archived: !!archiveEntry
1395
- };
1396
- const rest = stripContinue(target.content).trimStart();
1397
- const newContent = block + (archiveEntry ? archiveInto(rest, archiveEntry) : rest);
1398
- const tmpPath = `${target.path}.continue.tmp`;
1399
- try {
1400
- writeFileSync(tmpPath, newContent, "utf8");
1401
- renameSync(tmpPath, target.path);
1402
- } catch (err) {
1403
- try {
1404
- if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
1405
- } catch {}
1406
- return {
1407
- action: "failed",
1408
- path: target.path,
1409
- block,
1410
- error: String(err)
1411
- };
1412
- }
1413
- return {
1414
- action: "written",
1415
- path: target.path,
1416
- block,
1417
- carriedForward,
1418
- archived: !!archiveEntry
1419
- };
1420
- }
1421
- /** PAI_DIR — mirrors pai-paths.ts resolution. */
1422
- function getPaiDir() {
1423
- const envDir = process.env.PAI_DIR;
1424
- if (envDir) try {
1425
- return realpathSync(envDir);
1426
- } catch {
1427
- return envDir;
1428
- }
1429
- return join(homedir(), ".claude");
1430
- }
1431
- /**
1432
- * Find the notes directory for a project — local first, then the central
1433
- * ~/.claude/projects/<encoded>/Notes fallback. Never creates.
1434
- */
1435
- function findNotesDir(rootPath, encodedDir) {
1436
- for (const rel of [
1437
- "Notes",
1438
- "notes",
1439
- ".claude/Notes"
1440
- ]) {
1441
- const p = join(rootPath, rel);
1442
- if (existsSync(p)) return p;
1443
- }
1444
- const central = join(getPaiDir(), "projects", encodedDir, "Notes");
1445
- if (existsSync(central)) return central;
1446
- return null;
1447
- }
1448
- /**
1449
- * Find the current (highest-numbered) session note: current month, then the
1450
- * previous month, then a flat notesDir as legacy fallback.
1451
- */
1452
- function findLatestNote(notesDir) {
1453
- const findIn = (dir) => {
1454
- if (!existsSync(dir)) return null;
1455
- let files;
1456
- try {
1457
- files = readdirSync(dir);
1458
- } catch {
1459
- return null;
1460
- }
1461
- const notes = files.filter((f) => /^\d{3,4}[\s_-].*\.md$/.test(f)).sort((a, b) => {
1462
- return parseInt(a.match(/^(\d+)/)?.[1] ?? "0", 10) - parseInt(b.match(/^(\d+)/)?.[1] ?? "0", 10);
1463
- });
1464
- return notes.length > 0 ? join(dir, notes[notes.length - 1]) : null;
1465
- };
1466
- const now = /* @__PURE__ */ new Date();
1467
- const current = findIn(join(notesDir, String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, "0")));
1468
- if (current) return current;
1469
- const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
1470
- const prevFound = findIn(join(notesDir, String(prev.getFullYear()), String(prev.getMonth() + 1).padStart(2, "0")));
1471
- if (prevFound) return prevFound;
1472
- return findIn(notesDir);
1473
- }
1474
- /**
1475
- * Append the checkpoint body to a session note.
1476
- *
1477
- * TODO.md's ## Continue is a single slot that every later checkpoint
1478
- * overwrites. The session note is the durable record, so the body goes to both.
1479
- * Idempotent per timestamp: re-running with the same stamp will not duplicate.
1480
- */
1481
- function appendCheckpointToNote(notePath, body, timestamp) {
1482
- const heading = `## Pause Checkpoint — ${timestamp ?? (/* @__PURE__ */ new Date()).toISOString()}`;
1483
- let existing;
1484
- try {
1485
- existing = readFileSync(notePath, "utf8");
1486
- } catch (err) {
1487
- return {
1488
- appended: false,
1489
- error: String(err)
1490
- };
1491
- }
1492
- if (existing.includes(heading)) return { appended: false };
1493
- const block = `\n\n---\n\n${heading}\n\n${body.trim()}\n`;
1494
- const tmpPath = `${notePath}.checkpoint.tmp`;
1495
- try {
1496
- writeFileSync(tmpPath, existing.trimEnd() + block, "utf8");
1497
- renameSync(tmpPath, notePath);
1498
- } catch (err) {
1499
- try {
1500
- if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);
1501
- } catch {}
1502
- return {
1503
- appended: false,
1504
- error: String(err)
1505
- };
1506
- }
1507
- return { appended: true };
1508
- }
1509
- /** Read a checkpoint body from a file, or from stdin when path is "-". */
1510
- function readBodyFile(path) {
1511
- if (path === "-") return readFileSync(0, "utf8");
1512
- return readFileSync(path, "utf8");
1513
- }
1514
-
1515
- //#endregion
1516
- //#region src/hooks/ts/lib/context-handover-cache.ts
1517
- /**
1518
- * context-handover-cache.ts — the well-known file where the threshold-
1519
- * triggered pre-compaction handover (see ../../../daemon/context-handover-
1520
- * worker.ts) is written, and where context-compression-hook.ts looks for it
1521
- * at compaction time.
1522
- *
1523
- * Deliberately dependency-light (fs/os/path only). The daemon worker that
1524
- * WRITES this file needs the heavier machinery in session-summary-worker.ts
1525
- * (spawning Claude, git log, etc.), but the PreCompact hook that READS it
1526
- * runs as a short-lived process on every compaction and must not drag in
1527
- * daemon state, database pools, or anything else that module graph carries —
1528
- * this file is the shared seam so neither side has to.
1529
- */
1530
- function contextHandoverCachePath(sessionId) {
1531
- return join(tmpdir(), `pai-context-handover-${sessionId}.json`);
1532
- }
1533
- function readContextHandoverCache(sessionId) {
1534
- const path = contextHandoverCachePath(sessionId);
1535
- if (!existsSync(path)) return null;
1536
- try {
1537
- return JSON.parse(readFileSync(path, "utf-8"));
1538
- } catch {
1539
- return null;
1540
- }
1541
- }
1542
- function writeContextHandoverCache(cache) {
1543
- writeFileSync(contextHandoverCachePath(cache.sessionId), JSON.stringify(cache, null, 2), "utf-8");
1544
- }
1545
-
1546
- //#endregion
1547
- export { claudeProjectsDir as _, findLatestNote as a, transcriptFiles as b, extractAndStoreTriples as c, loadScanConfig as d, performScan as f, upsertSession as g, upsertProject as h, applyContinue as i, planLlmSpawn as l, saveScanConfig as m, writeContextHandoverCache as n, findNotesDir as o, resolveHome as p, appendCheckpointToNote as r, readBodyFile as s, readContextHandoverCache as t, cmdScan as u, findMovedProjects as v, scanTranscriptFolders as y };
1548
- //# sourceMappingURL=context-handover-cache-pNFdBNrU.mjs.map