@tekmidian/pai 0.15.0 → 0.17.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/README.md +11 -3
  2. package/dist/{auto-route-CLYcToZ5.mjs → auto-route-CdvjEWs5.mjs} +4 -4
  3. package/dist/{auto-route-CLYcToZ5.mjs.map → auto-route-CdvjEWs5.mjs.map} +1 -1
  4. package/dist/auto-route-DkIRampF.mjs +86 -0
  5. package/dist/auto-route-DkIRampF.mjs.map +1 -0
  6. package/dist/{kg-extraction-C8DEUHTS.mjs → checkpoint-block-Bl0HD3U2.mjs} +442 -13
  7. package/dist/checkpoint-block-Bl0HD3U2.mjs.map +1 -0
  8. package/dist/checkpoint-block-C05sK_HK.mjs +1056 -0
  9. package/dist/checkpoint-block-C05sK_HK.mjs.map +1 -0
  10. package/dist/checkpoint-block-CUCG10Uh.mjs +1022 -0
  11. package/dist/checkpoint-block-CUCG10Uh.mjs.map +1 -0
  12. package/dist/checkpoint-block-D5IyLLTr.mjs +1062 -0
  13. package/dist/checkpoint-block-D5IyLLTr.mjs.map +1 -0
  14. package/dist/cli/index.mjs +14 -12
  15. package/dist/cli/index.mjs.map +1 -1
  16. package/dist/cli/program.d.mts.map +1 -1
  17. package/dist/cli/program.mjs +31 -16
  18. package/dist/cli/program.mjs.map +1 -1
  19. package/dist/{clusters-BYPw7vfW.mjs → clusters-CKWDwcMX.mjs} +2 -2
  20. package/dist/{clusters-BYPw7vfW.mjs.map → clusters-CKWDwcMX.mjs.map} +1 -1
  21. package/dist/{config-C8m-tPhP.mjs → config-DGOfoGfm.mjs} +1 -1
  22. package/dist/{config-C8m-tPhP.mjs.map → config-DGOfoGfm.mjs.map} +1 -1
  23. package/dist/daemon/index.mjs +18 -16
  24. package/dist/daemon/index.mjs.map +1 -1
  25. package/dist/{daemon-COSvHTNu.mjs → daemon-1rKbN-fd.mjs} +31 -30
  26. package/dist/{daemon-COSvHTNu.mjs.map → daemon-1rKbN-fd.mjs.map} +1 -1
  27. package/dist/daemon-9AL6Pwh-.mjs +1581 -0
  28. package/dist/daemon-9AL6Pwh-.mjs.map +1 -0
  29. package/dist/daemon-Bbk-_rV6.mjs +1581 -0
  30. package/dist/daemon-Bbk-_rV6.mjs.map +1 -0
  31. package/dist/daemon-BvcaMk4g.mjs +1581 -0
  32. package/dist/daemon-BvcaMk4g.mjs.map +1 -0
  33. package/dist/daemon-CTDEfwcq.mjs +1576 -0
  34. package/dist/daemon-CTDEfwcq.mjs.map +1 -0
  35. package/dist/daemon-ClbfaF60.mjs +1576 -0
  36. package/dist/daemon-ClbfaF60.mjs.map +1 -0
  37. package/dist/daemon-DDQNDgYF.mjs +1576 -0
  38. package/dist/daemon-DDQNDgYF.mjs.map +1 -0
  39. package/dist/daemon-DU8yDWlQ.mjs +1576 -0
  40. package/dist/daemon-DU8yDWlQ.mjs.map +1 -0
  41. package/dist/daemon-NJr4iSW3.mjs +1576 -0
  42. package/dist/daemon-NJr4iSW3.mjs.map +1 -0
  43. package/dist/daemon-mcp/index.mjs +2 -2
  44. package/dist/db-BtuN768f.mjs +206 -0
  45. package/dist/db-BtuN768f.mjs.map +1 -0
  46. package/dist/{db-CmYbAVCD.mjs → db-CYmBWcjh.mjs} +1 -1
  47. package/dist/{db-CmYbAVCD.mjs.map → db-CYmBWcjh.mjs.map} +1 -1
  48. package/dist/{detect-KjycLtXM.mjs → detect-Bf2z-oKB.mjs} +1 -1
  49. package/dist/{detect-KjycLtXM.mjs.map → detect-Bf2z-oKB.mjs.map} +1 -1
  50. package/dist/{detector-ZiOhHszd.mjs → detector-Bwzm3E_x.mjs} +2 -2
  51. package/dist/{detector-ZiOhHszd.mjs.map → detector-Bwzm3E_x.mjs.map} +1 -1
  52. package/dist/detector-rq9JGTmS.mjs +74 -0
  53. package/dist/detector-rq9JGTmS.mjs.map +1 -0
  54. package/dist/embeddings-Bn86ssxR.mjs +119 -0
  55. package/dist/embeddings-Bn86ssxR.mjs.map +1 -0
  56. package/dist/{embeddings-BJPOcbik.mjs → embeddings-DGRAPAYb.mjs} +1 -1
  57. package/dist/{embeddings-BJPOcbik.mjs.map → embeddings-DGRAPAYb.mjs.map} +1 -1
  58. package/dist/{factory-Q88X1bAN.mjs → factory-0-57Ac-7.mjs} +8 -5
  59. package/dist/{factory-Q88X1bAN.mjs.map → factory-0-57Ac-7.mjs.map} +1 -1
  60. package/dist/factory-HcQsQqh0.mjs +72 -0
  61. package/dist/factory-HcQsQqh0.mjs.map +1 -0
  62. package/dist/factory-PDXQdkLO.mjs +72 -0
  63. package/dist/factory-PDXQdkLO.mjs.map +1 -0
  64. package/dist/{helpers-IjZkXBhj.mjs → helpers-crDEr6S2.mjs} +1 -1
  65. package/dist/{helpers-IjZkXBhj.mjs.map → helpers-crDEr6S2.mjs.map} +1 -1
  66. package/dist/hooks/capture-all-events.mjs.map +1 -1
  67. package/dist/hooks/capture-session-summary.mjs.map +1 -1
  68. package/dist/hooks/capture-tool-output.mjs.map +1 -1
  69. package/dist/hooks/cleanup-session-files.mjs.map +1 -1
  70. package/dist/hooks/context-compression-hook.mjs +336 -48
  71. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  72. package/dist/hooks/initialize-session.mjs.map +1 -1
  73. package/dist/hooks/inject-observations.mjs.map +1 -1
  74. package/dist/hooks/load-core-context.mjs.map +1 -1
  75. package/dist/hooks/load-project-context.mjs +188 -6
  76. package/dist/hooks/load-project-context.mjs.map +4 -4
  77. package/dist/hooks/observe.mjs.map +1 -1
  78. package/dist/hooks/post-compact-inject.mjs.map +1 -1
  79. package/dist/hooks/security-validator.mjs.map +1 -1
  80. package/dist/hooks/session-commands.mjs.map +1 -1
  81. package/dist/hooks/stop-hook.mjs +331 -43
  82. package/dist/hooks/stop-hook.mjs.map +4 -4
  83. package/dist/hooks/subagent-stop-hook.mjs.map +1 -1
  84. package/dist/hooks/sync-todo-to-md.mjs.map +1 -1
  85. package/dist/hooks/update-tab-on-action.mjs.map +1 -1
  86. package/dist/hooks/update-tab-titles.mjs.map +1 -1
  87. package/dist/hooks/whisper-rules.mjs.map +1 -1
  88. package/dist/index.d.mts +0 -9
  89. package/dist/index.d.mts.map +1 -1
  90. package/dist/index.mjs +10 -9
  91. package/dist/indexer-AEcT8wHf.mjs +1 -0
  92. package/dist/{indexer-backend-Cm5RS7y0.mjs → indexer-backend-BQZXoarl.mjs} +3 -3
  93. package/dist/{indexer-backend-Cm5RS7y0.mjs.map → indexer-backend-BQZXoarl.mjs.map} +1 -1
  94. package/dist/indexer-backend-BmaS2VcD.mjs +299 -0
  95. package/dist/indexer-backend-BmaS2VcD.mjs.map +1 -0
  96. package/dist/indexer-backend-COrJ5rjS.mjs +299 -0
  97. package/dist/indexer-backend-COrJ5rjS.mjs.map +1 -0
  98. package/dist/indexer-backend-NwD7TBGh.mjs +301 -0
  99. package/dist/indexer-backend-NwD7TBGh.mjs.map +1 -0
  100. package/dist/{ipc-client-CoyUHPod.mjs → ipc-client-vO2GV325.mjs} +1 -1
  101. package/dist/{ipc-client-CoyUHPod.mjs.map → ipc-client-vO2GV325.mjs.map} +1 -1
  102. package/dist/{kg-entity-LXblD7LZ.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  103. package/dist/{kg-entity-LXblD7LZ.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  104. package/dist/{latent-ideas-RyC6eyqI.mjs → latent-ideas-BwM3WdBp.mjs} +4 -4
  105. package/dist/{latent-ideas-RyC6eyqI.mjs.map → latent-ideas-BwM3WdBp.mjs.map} +1 -1
  106. package/dist/latent-ideas-C6IFFT0-.mjs +191 -0
  107. package/dist/latent-ideas-C6IFFT0-.mjs.map +1 -0
  108. package/dist/{main-resolver-uFxNiDy7.mjs → main-resolver-DimnS2qP.mjs} +22 -22
  109. package/dist/{main-resolver-uFxNiDy7.mjs.map → main-resolver-DimnS2qP.mjs.map} +1 -1
  110. package/dist/{migrate-B02wDDgS.mjs → migrate-fLD6rAdO.mjs} +2 -2
  111. package/dist/{migrate-B02wDDgS.mjs.map → migrate-fLD6rAdO.mjs.map} +1 -1
  112. package/dist/{neighborhood-CklGIB8r.mjs → neighborhood-CzRNG2Oy.mjs} +2 -2
  113. package/dist/{neighborhood-CklGIB8r.mjs.map → neighborhood-CzRNG2Oy.mjs.map} +1 -1
  114. package/dist/neighborhood-DZY0wkpE.mjs +135 -0
  115. package/dist/neighborhood-DZY0wkpE.mjs.map +1 -0
  116. package/dist/{note-context-qvZlVXVO.mjs → note-context-CrfMbr4R.mjs} +1 -1
  117. package/dist/{note-context-qvZlVXVO.mjs.map → note-context-CrfMbr4R.mjs.map} +1 -1
  118. package/dist/{pai-marker-HVBwwBW-.mjs → pai-marker-B20KqhA8.mjs} +44 -2
  119. package/dist/pai-marker-B20KqhA8.mjs.map +1 -0
  120. package/dist/pick-B0q5cy79.mjs +11785 -0
  121. package/dist/pick-B0q5cy79.mjs.map +1 -0
  122. package/dist/{pick-r95zvydr.mjs → pick-B3zEYjin.mjs} +2288 -497
  123. package/dist/pick-B3zEYjin.mjs.map +1 -0
  124. package/dist/pick-BHE9_iA0.mjs +11947 -0
  125. package/dist/pick-BHE9_iA0.mjs.map +1 -0
  126. package/dist/pick-BaTjoNqM.mjs +11544 -0
  127. package/dist/pick-BaTjoNqM.mjs.map +1 -0
  128. package/dist/pick-C6JbZfN4.mjs +11893 -0
  129. package/dist/pick-C6JbZfN4.mjs.map +1 -0
  130. package/dist/pick-CK-bJyjp.mjs +11883 -0
  131. package/dist/pick-CK-bJyjp.mjs.map +1 -0
  132. package/dist/pick-CLBkGHyI.mjs +11826 -0
  133. package/dist/pick-CLBkGHyI.mjs.map +1 -0
  134. package/dist/pick-CTOif21h.mjs +11544 -0
  135. package/dist/pick-CTOif21h.mjs.map +1 -0
  136. package/dist/pick-Ck2cdQgD.mjs +11893 -0
  137. package/dist/pick-Ck2cdQgD.mjs.map +1 -0
  138. package/dist/pick-Cr4nJzHA.mjs +11914 -0
  139. package/dist/pick-Cr4nJzHA.mjs.map +1 -0
  140. package/dist/pick-D4dFfKAu.mjs +11893 -0
  141. package/dist/pick-D4dFfKAu.mjs.map +1 -0
  142. package/dist/pick-D7-lYiGX.mjs +11543 -0
  143. package/dist/pick-D7-lYiGX.mjs.map +1 -0
  144. package/dist/pick-DMjCY0np.mjs +11875 -0
  145. package/dist/pick-DMjCY0np.mjs.map +1 -0
  146. package/dist/pick-DbX4OrCP.mjs +11543 -0
  147. package/dist/pick-DbX4OrCP.mjs.map +1 -0
  148. package/dist/pick-iCiPjlnC.mjs +11893 -0
  149. package/dist/pick-iCiPjlnC.mjs.map +1 -0
  150. package/dist/pick-sF9GnQUS.mjs +11893 -0
  151. package/dist/pick-sF9GnQUS.mjs.map +1 -0
  152. package/dist/pick-yHKUrnfG.mjs +11793 -0
  153. package/dist/pick-yHKUrnfG.mjs.map +1 -0
  154. package/dist/postgres-BJxiqtqg.mjs +891 -0
  155. package/dist/postgres-BJxiqtqg.mjs.map +1 -0
  156. package/dist/{postgres-B451Hnkm.mjs → postgres-zjKqPl4X.mjs} +2 -2
  157. package/dist/{postgres-B451Hnkm.mjs.map → postgres-zjKqPl4X.mjs.map} +1 -1
  158. package/dist/{query-feedback-fapbqRgh.mjs → query-feedback-BoY8_Dbb.mjs} +1 -1
  159. package/dist/{query-feedback-fapbqRgh.mjs.map → query-feedback-BoY8_Dbb.mjs.map} +1 -1
  160. package/dist/{reranker-C08R99zn.mjs → reranker-CMNZcfVx.mjs} +1 -1
  161. package/dist/{reranker-C08R99zn.mjs.map → reranker-CMNZcfVx.mjs.map} +1 -1
  162. package/dist/{search-HcdKtMla.mjs → search-CpTv1I24.mjs} +3 -3
  163. package/dist/{search-HcdKtMla.mjs.map → search-CpTv1I24.mjs.map} +1 -1
  164. package/dist/search-i2nlQ-JM.mjs +298 -0
  165. package/dist/search-i2nlQ-JM.mjs.map +1 -0
  166. package/dist/skills/End/SKILL.md +78 -0
  167. package/dist/skills/Pause/SKILL.md +84 -0
  168. package/dist/sqlite-CZ0LFili.mjs +271 -0
  169. package/dist/sqlite-CZ0LFili.mjs.map +1 -0
  170. package/dist/{sqlite-DQpY1Esi.mjs → sqlite-ChSCYwiC.mjs} +3 -3
  171. package/dist/{sqlite-DQpY1Esi.mjs.map → sqlite-ChSCYwiC.mjs.map} +1 -1
  172. package/dist/sqlite-Nw8ZW5Rj.mjs +271 -0
  173. package/dist/sqlite-Nw8ZW5Rj.mjs.map +1 -0
  174. package/dist/{state-BIlxNRUn.mjs → state-BXIdbxDs.mjs} +1 -1
  175. package/dist/{state-BIlxNRUn.mjs.map → state-BXIdbxDs.mjs.map} +1 -1
  176. package/dist/{stop-words-BwplsQ3z.mjs → stop-words-BaMEGVeY.mjs} +1 -1
  177. package/dist/{stop-words-BwplsQ3z.mjs.map → stop-words-BaMEGVeY.mjs.map} +1 -1
  178. package/dist/{sync-uPR4g438.mjs → sync--BoxBBok.mjs} +6 -205
  179. package/dist/sync--BoxBBok.mjs.map +1 -0
  180. package/dist/sync-CmBKOL3K.mjs +310 -0
  181. package/dist/sync-CmBKOL3K.mjs.map +1 -0
  182. package/dist/{themes-Cg8oG9Ra.mjs → themes-BBOlGXAg.mjs} +3 -3
  183. package/dist/{themes-Cg8oG9Ra.mjs.map → themes-BBOlGXAg.mjs.map} +1 -1
  184. package/dist/themes-nvRM8v84.mjs +148 -0
  185. package/dist/themes-nvRM8v84.mjs.map +1 -0
  186. package/dist/tools-B6BmaAvz.mjs +1939 -0
  187. package/dist/tools-B6BmaAvz.mjs.map +1 -0
  188. package/dist/tools-CAIY8TAb.mjs +1939 -0
  189. package/dist/tools-CAIY8TAb.mjs.map +1 -0
  190. package/dist/{tools-Op3C2Nm6.mjs → tools-CznjzfYz.mjs} +26 -26
  191. package/dist/{tools-Op3C2Nm6.mjs.map → tools-CznjzfYz.mjs.map} +1 -1
  192. package/dist/{trace-CLK-NPkb.mjs → trace-B8oz1ok0.mjs} +1 -1
  193. package/dist/{trace-CLK-NPkb.mjs.map → trace-B8oz1ok0.mjs.map} +1 -1
  194. package/dist/{utils-CqqgB0dH.mjs → utils-BAxjW3j8.mjs} +1 -1
  195. package/dist/{utils-CqqgB0dH.mjs.map → utils-BAxjW3j8.mjs.map} +1 -1
  196. package/dist/{vault-indexer-Dn_BccVK.mjs → vault-indexer-DrttVeEk.mjs} +2 -2
  197. package/dist/{vault-indexer-Dn_BccVK.mjs.map → vault-indexer-DrttVeEk.mjs.map} +1 -1
  198. package/dist/{work-queue-worker-BwouhVkb.mjs → work-queue-worker-BPXLkEik.mjs} +47 -30
  199. package/dist/work-queue-worker-BPXLkEik.mjs.map +1 -0
  200. package/dist/work-queue-worker-BoHIH70K.mjs +1856 -0
  201. package/dist/work-queue-worker-BoHIH70K.mjs.map +1 -0
  202. package/dist/work-queue-worker-CZGcQoLw.mjs +1856 -0
  203. package/dist/work-queue-worker-CZGcQoLw.mjs.map +1 -0
  204. package/dist/work-queue-worker-D05Ze9e9.mjs +1856 -0
  205. package/dist/work-queue-worker-D05Ze9e9.mjs.map +1 -0
  206. package/dist/work-queue-worker-DdXFINfH.mjs +1856 -0
  207. package/dist/work-queue-worker-DdXFINfH.mjs.map +1 -0
  208. package/dist/{zettelkasten-NwBD1NsT.mjs → zettelkasten-CTlt1dGv.mjs} +4 -4
  209. package/dist/{zettelkasten-NwBD1NsT.mjs.map → zettelkasten-CTlt1dGv.mjs.map} +1 -1
  210. package/dist/zettelkasten-D0N_A_OE.mjs +1063 -0
  211. package/dist/zettelkasten-D0N_A_OE.mjs.map +1 -0
  212. package/docs/commands/README.md +18 -0
  213. package/docs/commands/backup.md +1 -1
  214. package/docs/commands/clear-names.md +1 -1
  215. package/docs/commands/daemon.md +1 -1
  216. package/docs/commands/db.md +1 -1
  217. package/docs/commands/end.md +4 -1
  218. package/docs/commands/help.md +1 -1
  219. package/docs/commands/kg.md +1 -1
  220. package/docs/commands/mcp.md +1 -1
  221. package/docs/commands/memory.md +1 -1
  222. package/docs/commands/notify.md +1 -1
  223. package/docs/commands/observation.md +1 -1
  224. package/docs/commands/obsidian.md +1 -1
  225. package/docs/commands/pause.md +5 -2
  226. package/docs/commands/project.md +1 -1
  227. package/docs/commands/projects.md +1 -1
  228. package/docs/commands/registry.md +18 -1
  229. package/docs/commands/restore.md +1 -1
  230. package/docs/commands/session.md +295 -0
  231. package/docs/commands/sessions.md +1 -1
  232. package/docs/commands/setup.md +1 -1
  233. package/docs/commands/shell-init.md +1 -1
  234. package/docs/commands/skill.md +1 -1
  235. package/docs/commands/task.md +1 -1
  236. package/docs/commands/topic.md +1 -1
  237. package/docs/commands/update.md +1 -1
  238. package/docs/commands/zettel.md +1 -1
  239. package/package.json +1 -1
  240. package/plugins/context-preservation/hooks/hooks.json +12 -1
  241. package/scripts/build-hooks.mjs +33 -3
  242. package/src/hooks/session-autosave.sh +69 -0
  243. package/src/hooks/session-stop.sh +32 -1
  244. package/src/hooks/ts/lib/project-utils/todo.test.ts +132 -0
  245. package/src/hooks/ts/lib/project-utils/todo.ts +60 -34
  246. package/src/hooks/ts/session-start/load-project-context.ts +44 -0
  247. package/dist/kg-extraction-C8DEUHTS.mjs.map +0 -1
  248. package/dist/pai-marker-HVBwwBW-.mjs.map +0 -1
  249. package/dist/pick-r95zvydr.mjs.map +0 -1
  250. package/dist/sync-uPR4g438.mjs.map +0 -1
  251. package/dist/work-queue-worker-BwouhVkb.mjs.map +0 -1
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { updateTodoContinue } from "./todo.js";
6
+ import { applyContinue } from "../../../../session/checkpoint-block.js";
7
+
8
+ /**
9
+ * updateTodoContinue is the unattended writer — the pre-compact hook and the
10
+ * daemon work-queue worker both come through it. It is the function that
11
+ * actually destroyed model-authored checkpoints: `pai pause` wrote one, this
12
+ * ran on session end, and the checkpoint became
13
+ * "Working directory: … Check the latest session note for details."
14
+ */
15
+
16
+ const NOTE = "0002 - 2026-08-01 - Token Burn Diagnosis";
17
+ const RICH_BODY = [
18
+ "### Open decisions",
19
+ "",
20
+ "- Whether the daily check needs an owner",
21
+ "",
22
+ "---",
23
+ "",
24
+ "### Watch",
25
+ "",
26
+ "- Whisper trim: two rules changed at once.",
27
+ ].join("\n");
28
+
29
+ let root: string;
30
+
31
+ beforeEach(() => {
32
+ root = mkdtempSync(join(tmpdir(), "pai-todo-test-"));
33
+ mkdirSync(join(root, "Notes"), { recursive: true });
34
+ writeFileSync(
35
+ join(root, "Notes", "TODO.md"),
36
+ "## Infrastructure\n\nDo not lose me.\n",
37
+ "utf8"
38
+ );
39
+ });
40
+
41
+ afterEach(() => {
42
+ rmSync(root, { recursive: true, force: true });
43
+ });
44
+
45
+ function readTodo(): string {
46
+ return readFileSync(join(root, "Notes", "TODO.md"), "utf8");
47
+ }
48
+
49
+ describe("updateTodoContinue", () => {
50
+ it("does not destroy an authored checkpoint for the same session", () => {
51
+ // What `pai pause --body-file` does.
52
+ applyContinue({
53
+ rootPath: root,
54
+ authored: "model",
55
+ sessionLine: NOTE,
56
+ sessionId: "d840b282-7fc1-4cf6-b51e-8a951f6d88e6",
57
+ cwd: root,
58
+ body: RICH_BODY,
59
+ });
60
+
61
+ // What the daemon does seconds later on session end. This is the exact
62
+ // call that used to wipe it.
63
+ updateTodoContinue(
64
+ root,
65
+ `${NOTE}.md`,
66
+ `Working directory: ${root}`,
67
+ "session-end"
68
+ );
69
+
70
+ const out = readTodo();
71
+ expect(out).toContain("Whisper trim: two rules changed at once.");
72
+ expect(out).toContain("### Open decisions");
73
+ expect(out).toContain("claude --resume d840b282-7fc1-4cf6-b51e-8a951f6d88e6");
74
+ expect(out).toContain("Do not lose me.");
75
+ });
76
+
77
+ it("still writes when there is no authored checkpoint to protect", () => {
78
+ updateTodoContinue(root, `${NOTE}.md`, "Some auto state", "session-end");
79
+
80
+ const out = readTodo();
81
+ expect(out).toContain("## Continue");
82
+ expect(out).toContain(NOTE);
83
+ expect(out).toContain("Some auto state");
84
+ expect(out).toContain("Do not lose me.");
85
+ });
86
+
87
+ it("replaces an authored checkpoint belonging to an earlier session", () => {
88
+ applyContinue({
89
+ rootPath: root,
90
+ authored: "model",
91
+ sessionLine: "0001 - 2026-07-31 - Earlier",
92
+ cwd: root,
93
+ body: "Stale checkpoint.",
94
+ });
95
+
96
+ updateTodoContinue(root, `${NOTE}.md`, "Fresh auto state", "session-end");
97
+
98
+ const out = readTodo();
99
+ expect(out).not.toContain("Stale checkpoint.");
100
+ expect(out).toContain("Fresh auto state");
101
+ });
102
+
103
+ it("does not shred a rich body at the first --- inside it", () => {
104
+ // The old strip regex was /## Continue\n[\s\S]*?\n---\n+/ — non-greedy to
105
+ // the first horizontal rule, which for this body is in the middle.
106
+ applyContinue({
107
+ rootPath: root,
108
+ authored: "model",
109
+ sessionLine: "0001 - 2026-07-31 - Earlier",
110
+ cwd: root,
111
+ body: RICH_BODY,
112
+ });
113
+
114
+ updateTodoContinue(root, `${NOTE}.md`, "Fresh auto state", "session-end");
115
+
116
+ const out = readTodo();
117
+ // Replaced wholesale — no orphaned tail left behind in the document.
118
+ expect(out).not.toContain("### Watch");
119
+ expect(out).not.toContain("Whisper trim");
120
+ expect(out).toContain("Do not lose me.");
121
+ expect(out.split("## Continue").length - 1).toBe(1);
122
+ });
123
+
124
+ it("refreshes the Last updated stamp without duplicating it", () => {
125
+ updateTodoContinue(root, `${NOTE}.md`, "First", "session-end");
126
+ updateTodoContinue(root, `${NOTE}.md`, "Second", "session-end");
127
+
128
+ const out = readTodo();
129
+ expect(out.split("*Last updated:").length - 1).toBe(1);
130
+ expect(out).toContain("Second");
131
+ });
132
+ });
@@ -5,6 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
6
  import { join } from 'path';
7
7
  import { findTodoPath } from './paths.js';
8
+ import { applyContinue } from '../../../../session/checkpoint-block.js';
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Types
@@ -130,8 +131,23 @@ export function addTodoCheckpoint(cwd: string, checkpoint: string): void {
130
131
 
131
132
  /**
132
133
  * Update the ## Continue section at the top of TODO.md.
133
- * Mirrors "pause session" behavior — gives the next session a starting point.
134
- * Replaces any existing ## Continue section.
134
+ *
135
+ * This is the unattended writer: the pre-compact hook and the daemon's
136
+ * work-queue worker both come through here. It used to build its own block and
137
+ * strip the existing section with `/## Continue\n[\s\S]*?\n---\n+/` — a
138
+ * non-greedy match to the first `---`, which cuts a rich checkpoint in half at
139
+ * the first horizontal rule inside its body and leaves the remainder orphaned
140
+ * in the document.
141
+ *
142
+ * More importantly it was the writer that actually destroyed model-authored
143
+ * checkpoints: `pai pause` wrote one, then this ran seconds later on session
144
+ * end and replaced it with `Working directory: … Check the latest session note
145
+ * for details.`
146
+ *
147
+ * It now delegates to the shared checkpoint module in "auto" mode, which means
148
+ * it inherits the preservation rules rather than reimplementing them. Guarding
149
+ * here rather than at each of the three call sites is deliberate: any future
150
+ * caller inherits the behaviour without knowing it exists.
135
151
  */
136
152
  export function updateTodoContinue(
137
153
  cwd: string,
@@ -139,40 +155,50 @@ export function updateTodoContinue(
139
155
  state: string | null,
140
156
  tokenDisplay: string
141
157
  ): void {
142
- const todoPath = ensureTodoMd(cwd);
143
- let content = readFileSync(todoPath, 'utf-8');
144
-
145
- // Remove existing ## Continue section
146
- content = content.replace(/## Continue\n[\s\S]*?\n---\n+/, '');
147
-
148
- const now = new Date().toISOString();
149
- const stateLines = state
150
- ? state.split('\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\n')
151
- : `> Working directory: ${cwd}. Check the latest session note for details.`;
152
-
153
- const continueSection = `## Continue
154
-
155
- > **Last session:** ${noteFilename.replace('.md', '')}
156
- > **Paused at:** ${now}
157
- >
158
- ${stateLines}
159
-
160
- ---
161
-
162
- `;
163
-
164
- content = content.replace(/^\s+/, '');
158
+ // Ensure a TODO.md exists so applyContinue writes to the same file the rest
159
+ // of the hooks lib uses.
160
+ ensureTodoMd(cwd);
161
+
162
+ const result = applyContinue({
163
+ rootPath: cwd,
164
+ authored: 'auto',
165
+ // The hooks identify a session by its note filename; `pai pause` resolves
166
+ // the same string from the registry (and falls back to this filename when
167
+ // the registry has no session row). They must agree, because this string
168
+ // is the key that decides whether an authored checkpoint belongs to the
169
+ // current session and must be preserved.
170
+ sessionLine: noteFilename.replace(/\.md$/, ''),
171
+ cwd,
172
+ body: state?.trim() || undefined,
173
+ });
174
+
175
+ if (result.action === 'preserved') {
176
+ console.error(
177
+ 'TODO.md ## Continue left intact — authored checkpoint for this session'
178
+ );
179
+ return;
180
+ }
165
181
 
166
- const titleMatch = content.match(/^(# [^\n]+\n+)/);
167
- if (titleMatch) {
168
- content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);
169
- } else {
170
- content = continueSection + content;
182
+ if (result.action === 'failed') {
183
+ console.error(`TODO.md ## Continue update failed: ${result.error}`);
184
+ return;
171
185
  }
172
186
 
173
- content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, '');
174
- content = content.trimEnd() + `\n\n---\n\n*Last updated: ${now}*\n`;
187
+ // Refresh the trailing "Last updated" stamp without disturbing the block.
188
+ try {
189
+ const todoPath = result.path!;
190
+ const now = new Date().toISOString();
191
+ let content = readFileSync(todoPath, 'utf-8');
192
+ content = content.replace(/(\n---\s*)*(\n\*Last updated:.*\*\s*)+$/g, '');
193
+ content = content.trimEnd() + `\n\n---\n\n*Last updated: ${now}*\n`;
194
+ writeFileSync(todoPath, content);
195
+ } catch {
196
+ // Non-fatal — the checkpoint itself is already written.
197
+ }
175
198
 
176
- writeFileSync(todoPath, content);
177
- console.error('TODO.md ## Continue section updated');
199
+ console.error(
200
+ result.carriedForward
201
+ ? 'TODO.md ## Continue section updated (previous content carried forward)'
202
+ : 'TODO.md ## Continue section updated'
203
+ );
178
204
  }
@@ -21,6 +21,7 @@ import { join, basename, dirname, resolve } from 'path';
21
21
  import { homedir } from 'os';
22
22
  import { execSync } from 'child_process';
23
23
  import { buildWakeupContext } from '../../../memory/wakeup.js';
24
+ import { readContinueCheckpoint } from '../../../session/checkpoint-block.js';
24
25
  import {
25
26
  PAI_DIR,
26
27
  findNotesDir,
@@ -489,6 +490,49 @@ Session Commands:
489
490
  // Output to stdout for Claude to receive
490
491
  console.log(reminder);
491
492
 
493
+ // 8.5. INJECT THE PAUSE CHECKPOINT
494
+ //
495
+ // `pai pause` writes the handover to TODO.md under `## Continue`. Until now
496
+ // nothing read it back: the session-commands hook only told the model to look
497
+ // there *if the user typed "go"*, so a resumed session started blind unless
498
+ // the user knew the magic word. Writing a handover nobody delivers is not a
499
+ // handover. Inject it, and say plainly that it is the previous session's
500
+ // state rather than an instruction to act on.
501
+ if (existsSync(todoPath)) {
502
+ try {
503
+ const checkpoint = readContinueCheckpoint(readFileSync(todoPath, 'utf-8'));
504
+ if (checkpoint) {
505
+ const from = checkpoint.meta?.session
506
+ ? `\nFrom session: ${checkpoint.meta.session}`
507
+ : '';
508
+ const when = checkpoint.meta?.ts ? `\nPaused at: ${checkpoint.meta.ts}` : '';
509
+ const resume = checkpoint.meta?.sessionId
510
+ ? `\nResume that session with: claude --resume ${checkpoint.meta.sessionId}`
511
+ : '';
512
+
513
+ console.log(`
514
+ <system-reminder>
515
+ HANDOVER FROM THE PREVIOUS SESSION
516
+
517
+ Source: ${todoPath} (## Continue)${from}${when}${resume}
518
+
519
+ ${checkpoint.body}
520
+
521
+ ---
522
+ This is recorded state, not a new instruction. Do not start acting on it
523
+ unprompted. If the user says "go", "continue", or "weiter", resume from here.
524
+ </system-reminder>
525
+ `);
526
+ console.error(`Injected pause checkpoint (${checkpoint.body.length} chars)`);
527
+ } else {
528
+ console.error('No pause checkpoint body in TODO.md — nothing to hand over');
529
+ }
530
+ } catch (error) {
531
+ // Non-fatal — a malformed TODO.md must never block session start.
532
+ console.error('Checkpoint injection failed:', error);
533
+ }
534
+ }
535
+
492
536
  // 9. INJECT CLAUDE.md contents as system-reminders
493
537
  // This ensures Claude actually reads and processes the instructions
494
538
  for (const { path, content } of claudeMdContents) {
@@ -1 +0,0 @@
1
- {"version":3,"file":"kg-extraction-C8DEUHTS.mjs","names":[],"sources":["../src/cli/commands/registry/utils.ts","../src/cli/commands/registry/scan.ts","../src/daemon/templates/triple-extraction-prompt.ts","../src/memory/kg-extraction.ts"],"sourcesContent":["/** Shared database helpers for registry command operations. */\n\nimport type { Database } from \"better-sqlite3\";\nimport { now } from \"../../utils.js\";\nimport { basename } from \"node:path\";\n\n/**\n * Upsert a project row. Returns { id, isNew }.\n *\n * Matching priority:\n * 1. root_path — most reliable; handles slug collisions\n * 2. encoded_dir — Claude project dirs are canonical\n * 3. Insert with suffix-deduplication on slug collision\n *\n * display_name is set to basename(rootPath) on INSERT so that the unified\n * listing always shows a human-readable name rather than the kebab-case slug.\n */\nexport function upsertProject(\n db: Database,\n slug: string,\n rootPath: string,\n encodedDir: string\n): { id: number; isNew: boolean } {\n const ts = now();\n\n const byPath = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined;\n\n if (byPath) {\n const encodedOwner = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(encodedDir) as { id: number } | undefined;\n\n if (!encodedOwner || encodedOwner.id === byPath.id) {\n db.prepare(\n \"UPDATE projects SET encoded_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(encodedDir, ts, byPath.id);\n }\n return { id: byPath.id, isNew: false };\n }\n\n const byEncoded = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(encodedDir) as { id: number } | undefined;\n\n if (byEncoded) {\n const pathOwner = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(rootPath) as { id: number } | undefined;\n\n if (!pathOwner || pathOwner.id === byEncoded.id) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?\"\n ).run(rootPath, ts, byEncoded.id);\n }\n return { id: byEncoded.id, isNew: false };\n }\n\n // Insert — deduplicate slug with numeric suffix if needed.\n let finalSlug = slug;\n let attempt = 0;\n while (true) {\n const conflict = db\n .prepare(\"SELECT id FROM projects WHERE slug = ?\")\n .get(finalSlug) as { id: number } | undefined;\n if (!conflict) break;\n attempt++;\n finalSlug = `${slug}-${attempt}`;\n }\n\n // Use basename(rootPath) as the human display name. If rootPath is empty or\n // just \"/\" fall back to the slug so we always have something non-empty.\n const displayName = basename(rootPath) || finalSlug;\n\n const result = db\n .prepare(\n `INSERT OR IGNORE INTO projects\n (slug, display_name, root_path, encoded_dir, type, status, created_at, updated_at)\n VALUES (?, ?, ?, ?, 'local', 'active', ?, ?)`\n )\n .run(finalSlug, displayName, rootPath, encodedDir, ts, ts);\n\n if (result.changes === 0) {\n const fallback =\n (db.prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\").get(encodedDir) as { id: number } | undefined) ??\n (db.prepare(\"SELECT id FROM projects WHERE root_path = ?\").get(rootPath) as { id: number } | undefined);\n\n if (fallback) {\n return { id: fallback.id, isNew: false };\n }\n\n throw new Error(\n `upsertProject: INSERT OR IGNORE was suppressed but no matching row found ` +\n `for root_path=${rootPath} encoded_dir=${encodedDir}`\n );\n }\n\n return { id: result.lastInsertRowid as number, isNew: true };\n}\n\n/** Upsert a session note. Returns true if newly inserted. */\nexport function upsertSession(\n db: Database,\n projectId: number,\n number: number,\n date: string,\n slug: string,\n title: string,\n filename: string\n): boolean {\n const existing = db\n .prepare(\"SELECT id FROM sessions WHERE project_id = ? AND number = ?\")\n .get(projectId, number);\n\n if (existing) return false;\n\n const ts = now();\n db.prepare(\n `INSERT INTO sessions\n (project_id, number, date, slug, title, filename, status, created_at)\n VALUES (?, ?, ?, ?, ?, ?, 'completed', ?)`\n ).run(projectId, number, date, slug, title, filename, ts);\n\n return true;\n}\n","/** Registry scan command: walk ~/.claude/projects/ and populate the registry. */\n\nimport { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { realpathSync } from \"node:fs\";\nimport { join, basename, resolve } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { ok, warn, err, dim, bold } from \"../../utils.js\";\nimport { encodeDir } from \"../../utils.js\";\nimport { decodeEncodedDir, slugify, parseSessionFilename, buildEncodedDirMap } from \"../../../registry/migrate.js\";\nimport { ensurePaiMarker, discoverPaiMarkers } from \"../../../registry/pai-marker.js\";\nimport { upsertProject, upsertSession } from \"./utils.js\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// clc session.json fallback map\n// ---------------------------------------------------------------------------\n\n/**\n * Build a reverse map from encoded-dir → real path using the clc session\n * registry (~/.claude/session.json).\n *\n * The clc registry stores { sessions: [{ directory, ... }, ...] }. For each\n * entry with a resolvable directory, we encode the realpath and add it to\n * the map. This lets the scanner recover project paths that are not in\n * Claude's session-registry.json but were registered by clc.\n */\nfunction buildClcDirMap(): Map<string, string> {\n const map = new Map<string, string>();\n const sessionFile = join(homedir(), \".claude\", \"session.json\");\n if (!existsSync(sessionFile)) return map;\n\n try {\n const raw = readFileSync(sessionFile, \"utf8\");\n const parsed = JSON.parse(raw) as { sessions?: Array<{ directory?: string }> };\n for (const entry of parsed.sessions ?? []) {\n const dir = entry.directory;\n if (!dir) continue;\n try {\n const real = realpathSync(dir);\n if (!existsSync(real)) continue;\n const encoded = encodeDir(real);\n map.set(encoded, real);\n } catch {\n // Unresolvable path — skip\n }\n }\n } catch {\n // Unparseable — return empty map\n }\n return map;\n}\n\n// ---------------------------------------------------------------------------\n// Config helpers\n// ---------------------------------------------------------------------------\n\nconst CLAUDE_PROJECTS_DIR = join(homedir(), \".claude\", \"projects\");\nconst PAI_CONFIG_DIR = join(homedir(), \".pai\");\nconst PAI_CONFIG_FILE = join(PAI_CONFIG_DIR, \"config.json\");\n\ninterface PaiConfig {\n scan_dirs: string[];\n}\n\nexport function loadScanConfig(): PaiConfig {\n if (!existsSync(PAI_CONFIG_FILE)) return { scan_dirs: [] };\n try {\n return JSON.parse(readFileSync(PAI_CONFIG_FILE, \"utf8\")) as PaiConfig;\n } catch {\n return { scan_dirs: [] };\n }\n}\n\nexport function saveScanConfig(config: PaiConfig): void {\n mkdirSync(PAI_CONFIG_DIR, { recursive: true });\n writeFileSync(PAI_CONFIG_FILE, JSON.stringify(config, null, 2) + \"\\n\", \"utf8\");\n}\n\nexport function resolveHome(p: string): string {\n if (p.startsWith(\"~/\")) return join(homedir(), p.slice(2));\n return resolve(p);\n}\n\n// ---------------------------------------------------------------------------\n// File discovery\n// ---------------------------------------------------------------------------\n\n/**\n * Recursively find all .md files in a directory, including YYYY/MM subdirectories.\n * Returns filenames (basename only).\n */\nexport function findNoteFiles(dir: string): string[] {\n const results: string[] = [];\n if (!existsSync(dir)) return results;\n\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (entry.isFile() && entry.name.endsWith(\".md\")) {\n results.push(entry.name);\n } else if (entry.isDirectory() && /^\\d{4}$/.test(entry.name)) {\n const yearDir = join(dir, entry.name);\n for (const monthEntry of readdirSync(yearDir, { withFileTypes: true })) {\n if (monthEntry.isDirectory() && /^\\d{2}$/.test(monthEntry.name)) {\n const monthDir = join(yearDir, monthEntry.name);\n for (const noteEntry of readdirSync(monthDir, { withFileTypes: true })) {\n if (noteEntry.isFile() && noteEntry.name.endsWith(\".md\")) {\n results.push(noteEntry.name);\n }\n }\n }\n }\n }\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Scan result type\n// ---------------------------------------------------------------------------\n\nexport interface ScanResult {\n projectsScanned: number;\n projectsNew: number;\n projectsUpdated: number;\n sessionsScanned: number;\n sessionsNew: number;\n skipped: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Core scan logic\n// ---------------------------------------------------------------------------\n\nexport function performScan(db: Database): ScanResult {\n const result: ScanResult = {\n projectsScanned: 0,\n projectsNew: 0,\n projectsUpdated: 0,\n sessionsScanned: 0,\n sessionsNew: 0,\n skipped: [],\n };\n\n if (!existsSync(CLAUDE_PROJECTS_DIR)) {\n throw new Error(`Claude projects directory not found: ${CLAUDE_PROJECTS_DIR}`);\n }\n\n const entries = readdirSync(CLAUDE_PROJECTS_DIR).filter((name) => {\n const full = join(CLAUDE_PROJECTS_DIR, name);\n return statSync(full).isDirectory();\n });\n\n const lookupMap = buildEncodedDirMap();\n const clcMap = buildClcDirMap();\n\n for (const encodedDir of entries) {\n let rootPath = decodeEncodedDir(encodedDir, lookupMap);\n\n // If the decoded path doesn't exist, try the clc session.json fallback map.\n // The clc map is keyed by encodeDir(realpathSync(directory)), so paths that\n // were registered by clc but not in session-registry.json can be recovered.\n if (!existsSync(rootPath) && clcMap.has(encodedDir)) {\n const clcPath = clcMap.get(encodedDir)!;\n if (existsSync(clcPath)) {\n rootPath = clcPath;\n }\n }\n\n if (!existsSync(rootPath)) {\n result.skipped.push(`${encodedDir} (decoded: ${rootPath} — path not found on disk)`);\n result.projectsScanned++;\n continue;\n }\n\n const slug = slugify(basename(rootPath) || encodedDir);\n const { id, isNew } = upsertProject(db, slug, rootPath, encodedDir);\n\n result.projectsScanned++;\n if (isNew) result.projectsNew++;\n else result.projectsUpdated++;\n\n try {\n ensurePaiMarker(rootPath, slug);\n } catch {\n // Non-fatal\n }\n\n const claudeNotesDir = join(CLAUDE_PROJECTS_DIR, encodedDir, \"Notes\");\n\n if (existsSync(claudeNotesDir)) {\n const rootNotesDir = join(rootPath, \"Notes\");\n if (claudeNotesDir !== rootNotesDir) {\n db.prepare(\n \"UPDATE projects SET claude_notes_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(claudeNotesDir, Date.now(), id);\n }\n }\n\n if (!existsSync(claudeNotesDir)) continue;\n\n const noteFiles = findNoteFiles(claudeNotesDir);\n\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n result.sessionsScanned++;\n const isNewSession = upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename);\n if (isNewSession) result.sessionsNew++;\n }\n }\n\n // Phase 2: Scan project-root Notes/ for all registered active projects\n {\n const activeProjects = db\n .prepare(\"SELECT id, slug, root_path FROM projects WHERE status = 'active'\")\n .all() as { id: number; slug: string; root_path: string }[];\n\n for (const project of activeProjects) {\n const notesDir = join(project.root_path, \"Notes\");\n if (!existsSync(notesDir)) continue;\n\n let files: string[];\n try {\n files = findNoteFiles(notesDir);\n } catch {\n continue;\n }\n\n for (const filename of files) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n\n result.sessionsScanned++;\n const isNewSession = upsertSession(db, project.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename);\n if (isNewSession) result.sessionsNew++;\n }\n }\n }\n\n // Phase 3: Scan extra directories from config\n const config = loadScanConfig();\n if (config.scan_dirs.length) {\n for (const rawDir of config.scan_dirs) {\n const scanDir = resolveHome(rawDir);\n if (!existsSync(scanDir)) {\n result.skipped.push(`${rawDir} (configured scan_dir not found)`);\n continue;\n }\n\n const children = readdirSync(scanDir).filter((name) => {\n if (name.startsWith(\".\")) return false;\n const full = join(scanDir, name);\n try { return statSync(full).isDirectory(); } catch { return false; }\n });\n\n for (const child of children) {\n const childPath = join(scanDir, child);\n const childSlug = slugify(child);\n const childEncoded = encodeDir(childPath);\n\n const existing = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(childPath) as { id: number } | undefined;\n\n if (existing) {\n result.projectsScanned++;\n result.projectsUpdated++;\n\n try { ensurePaiMarker(childPath, childSlug); } catch { /* non-fatal */ }\n\n const notesDir = join(childPath, \"Notes\");\n if (existsSync(notesDir)) {\n const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(\".md\"));\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n result.sessionsScanned++;\n if (upsertSession(db, existing.id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) {\n result.sessionsNew++;\n }\n }\n }\n continue;\n }\n\n const { id, isNew } = upsertProject(db, childSlug, childPath, childEncoded);\n result.projectsScanned++;\n if (isNew) result.projectsNew++;\n else result.projectsUpdated++;\n\n try { ensurePaiMarker(childPath, childSlug); } catch { /* non-fatal */ }\n\n const notesDir = join(childPath, \"Notes\");\n if (existsSync(notesDir)) {\n const noteFiles = readdirSync(notesDir).filter((f) => f.endsWith(\".md\"));\n for (const filename of noteFiles) {\n const parsed = parseSessionFilename(filename);\n if (!parsed) continue;\n result.sessionsScanned++;\n if (upsertSession(db, id, parsed.number, parsed.date, parsed.slug, parsed.title, parsed.filename)) {\n result.sessionsNew++;\n }\n }\n }\n }\n }\n }\n\n // Phase 4: Discover PAI.md markers in scan_dirs\n if (config.scan_dirs.length) {\n const resolvedScanDirs = config.scan_dirs.map(resolveHome).filter(existsSync);\n const markers = discoverPaiMarkers(resolvedScanDirs);\n\n for (const marker of markers) {\n const registeredRow = db\n .prepare(\"SELECT id, root_path, slug FROM projects WHERE slug = ?\")\n .get(marker.slug) as { id: number; root_path: string; slug: string } | undefined;\n\n if (!registeredRow) continue;\n\n if (registeredRow.root_path !== marker.projectRoot) {\n const newEncoded = encodeDir(marker.projectRoot);\n const now4 = Date.now();\n\n const encodedOwner = db\n .prepare(\"SELECT id FROM projects WHERE encoded_dir = ?\")\n .get(newEncoded) as { id: number } | undefined;\n const pathOwner = db\n .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n .get(marker.projectRoot) as { id: number } | undefined;\n\n const encodedSafe = !encodedOwner || encodedOwner.id === registeredRow.id;\n const pathSafe = !pathOwner || pathOwner.id === registeredRow.id;\n\n if (encodedSafe && pathSafe) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, encoded_dir = ?, updated_at = ? WHERE id = ?\"\n ).run(marker.projectRoot, newEncoded, now4, registeredRow.id);\n } else if (pathSafe) {\n db.prepare(\n \"UPDATE projects SET root_path = ?, updated_at = ? WHERE id = ?\"\n ).run(marker.projectRoot, now4, registeredRow.id);\n }\n }\n }\n }\n\n // Phase 5: Backfill display_name for rows where it still equals the slug.\n // This converts legacy entries created before the basename-based naming was\n // introduced: \"jobs-grazyna\" → \"Jobs Grazyna\" (from basename of root_path).\n {\n const stale = db\n .prepare(\"SELECT id, slug, root_path FROM projects WHERE display_name = slug AND root_path IS NOT NULL AND root_path != ''\")\n .all() as { id: number; slug: string; root_path: string }[];\n\n for (const row of stale) {\n const name = basename(row.root_path);\n if (name && name !== row.slug) {\n db.prepare(\"UPDATE projects SET display_name = ?, updated_at = ? WHERE id = ?\")\n .run(name, Date.now(), row.id);\n }\n }\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// cmdScan\n// ---------------------------------------------------------------------------\n\n/**\n * Run the registry scan CLI command.\n *\n * @param opts.quick When true, skips verbose output (same scan, less noise).\n * The underlying scan is always incremental via upsert —\n * this flag exists for hook/daemon-triggered invocations that\n * want minimal log output.\n */\nexport function cmdScan(db: Database, opts: { quick?: boolean } = {}): void {\n const config = loadScanConfig();\n if (!opts.quick) {\n console.log(dim(\"Scanning ~/.claude/projects/ ...\"));\n if (config.scan_dirs.length) {\n console.log(dim(`Scanning ${config.scan_dirs.length} extra dir(s): ${config.scan_dirs.join(\", \")}`));\n }\n console.log(dim(\"Scanning project-root Notes/ directories ...\"));\n }\n\n let result: ScanResult;\n try {\n result = performScan(db);\n } catch (e) {\n console.error(err(String(e)));\n process.exit(1);\n }\n\n if (!opts.quick) {\n console.log(\n ok(`Scanned ${bold(String(result.projectsScanned))} projects, ${bold(String(result.sessionsScanned))} session notes.`)\n );\n console.log(dim(` Projects: ${result.projectsNew} new, ${result.projectsUpdated} updated`));\n console.log(dim(` Sessions: ${result.sessionsNew} new`));\n\n if (result.skipped.length) {\n console.log();\n console.log(warn(` ${result.skipped.length} project(s) skipped (path not found on disk):`));\n for (const s of result.skipped.slice(0, 10)) {\n console.log(dim(` ${s}`));\n }\n if (result.skipped.length > 10) {\n console.log(dim(` ... and ${result.skipped.length - 10} more`));\n }\n }\n } else {\n // Quick mode: single compact line (stderr so it doesn't pollute pipe output)\n process.stderr.write(\n `[registry-scan] ${result.projectsScanned} projects, ${result.sessionsScanned} sessions ` +\n `(${result.projectsNew}+${result.sessionsNew} new).\\n`\n );\n }\n}\n","/**\n * triple-extraction-prompt.ts — Prompt template for KG triple extraction.\n *\n * Used by the session-summary-worker to extract structured facts from\n * a completed session summary and store them in the temporal knowledge graph.\n */\n\nexport function buildTripleExtractionPrompt(params: {\n sessionContent: string;\n projectSlug: string;\n gitLog: string;\n}): string {\n return `Extract structured entities and relations from this coding session.\n\nOutput a single JSON object with two arrays: \"entities\" and \"relations\".\n\nEntity types: project | person | concept | tool | file | version | decision | technology | organization\n\nRules:\n- Be SPECIFIC: entity names must be concrete (e.g., \"FSRS\", \"Glidr\", \"Matthias\")\n- Use snake_case relation verb phrases (e.g., \"uses_algorithm\", \"decided_to\", \"shipped_version\")\n- Skip opinions, speculation, and \"we should\" statements\n- Skip entities obvious from project metadata unless they have a meaningful relation\n- Maximum 15 relations per session — pick the most important\n- Each entity should have a brief description (1 sentence, what it is in this context)\n- Each relation must reference entity names that appear in the entities array\n\nExample output:\n{\n \"entities\": [\n {\"name\": \"Glidr\", \"type\": \"project\", \"description\": \"Flashcard app using FSRS spaced repetition\"},\n {\"name\": \"FSRS\", \"type\": \"concept\", \"description\": \"Free Spaced Repetition Scheduler algorithm\"},\n {\"name\": \"Matthias\", \"type\": \"person\", \"description\": \"Developer of Glidr and Quassl\"},\n {\"name\": \"Quassl\", \"type\": \"project\", \"description\": \"iOS app being rewritten in Flutter\"},\n {\"name\": \"Flutter\", \"type\": \"technology\", \"description\": \"Cross-platform mobile framework\"}\n ],\n \"relations\": [\n {\"source\": \"Glidr\", \"relation\": \"uses_algorithm\", \"target\": \"FSRS\"},\n {\"source\": \"Glidr\", \"relation\": \"shipped_version\", \"target\": \"1.0.5\"},\n {\"source\": \"Matthias\", \"relation\": \"decided_to_rewrite\", \"target\": \"Quassl\"},\n {\"source\": \"Quassl\", \"relation\": \"migrating_to\", \"target\": \"Flutter\"}\n ]\n}\n\nPROJECT: ${params.projectSlug}\n\nSESSION CONTENT:\n${params.sessionContent}\n\nGIT COMMITS:\n${params.gitLog}\n\nJSON object (entities + relations):`;\n}\n","/**\n * kg-extraction.ts — Shared KG triple extraction logic.\n *\n * Extracted from session-summary-worker.ts so both the worker and the\n * CLI backfill (`pai kg backfill`) can use the same code path.\n *\n * Provides:\n * - findClaudeBinary() — locate the claude CLI\n * - spawnClaude() — generic prompt -> response runner (strips ANTHROPIC_API_KEY)\n * - extractAndStoreTriples() — run the extractor prompt and persist triples to Postgres\n */\n\nimport { existsSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport type { Pool } from \"pg\";\nimport type { Database } from \"better-sqlite3\";\n\nimport { buildTripleExtractionPrompt } from \"../daemon/templates/triple-extraction-prompt.js\";\nimport { kgAdd, kgQuery, kgInvalidate } from \"./kg.js\";\nimport { upsertKgEntity } from \"./kg-entity.js\";\n\n// ---------------------------------------------------------------------------\n// Claude CLI binary discovery\n// ---------------------------------------------------------------------------\n\n/**\n * Find the `claude` CLI binary. Checks common installation locations first\n * (launchd PATH is minimal so bare \"claude\" often won't resolve).\n */\nexport function findClaudeBinary(): string | null {\n const candidates = [\n join(homedir(), \".local\", \"bin\", \"claude\"),\n join(homedir(), \".claude\", \"local\", \"claude\"),\n \"/usr/local/bin/claude\",\n \"/opt/homebrew/bin/claude\",\n ];\n\n for (const candidate of candidates) {\n try {\n if (existsSync(candidate)) return candidate;\n } catch { /* skip */ }\n }\n return \"claude\";\n}\n\nconst CLAUDE_TIMEOUT_MS: Record<string, number> = {\n haiku: 60_000,\n sonnet: 120_000,\n opus: 300_000,\n};\n\n/**\n * Spawn the claude CLI with a prompt on stdin and return stdout.\n *\n * IMPORTANT: ANTHROPIC_API_KEY is stripped from the spawned environment so\n * the CLI uses the user's Max plan (free) instead of billing the API key.\n */\nexport async function spawnClaude(\n prompt: string,\n model: \"haiku\" | \"sonnet\" | \"opus\" = \"sonnet\"\n): Promise<string | null> {\n const claudeBin = findClaudeBinary();\n if (!claudeBin) {\n process.stderr.write(\"[kg-extraction] claude CLI not found.\\n\");\n return null;\n }\n\n const { spawn } = await import(\"node:child_process\");\n\n return new Promise((resolve) => {\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n const { ANTHROPIC_API_KEY: _drop, ...envWithoutApiKey } = process.env;\n const child = spawn(\n claudeBin,\n [\"--model\", model, \"-p\", \"--no-session-persistence\"],\n { env: envWithoutApiKey, stdio: [\"pipe\", \"pipe\", \"pipe\"] }\n );\n\n let stdout = \"\";\n let stderr = \"\";\n\n child.stdout.on(\"data\", (chunk: Buffer) => { stdout += chunk.toString(); });\n child.stderr.on(\"data\", (chunk: Buffer) => { stderr += chunk.toString(); });\n\n child.on(\"error\", (err: Error) => {\n if (timer) { clearTimeout(timer); timer = null; }\n process.stderr.write(`[kg-extraction] ${model} spawn error: ${err.message}\\n`);\n resolve(null);\n });\n\n child.on(\"close\", (code: number | null) => {\n if (timer) { clearTimeout(timer); timer = null; }\n if (code !== 0) {\n process.stderr.write(\n `[kg-extraction] ${model} exited ${code}: ${stderr.slice(0, 300)}\\n`\n );\n resolve(null);\n } else {\n resolve(stdout.trim() || null);\n }\n });\n\n timer = setTimeout(() => {\n process.stderr.write(`[kg-extraction] ${model} timed out — killing process.\\n`);\n child.kill(\"SIGTERM\");\n resolve(null);\n }, CLAUDE_TIMEOUT_MS[model] ?? 120_000);\n\n child.stdin.write(prompt);\n child.stdin.end();\n });\n}\n\n// ---------------------------------------------------------------------------\n// Triple extraction\n// ---------------------------------------------------------------------------\n\nexport interface ExtractTriplesParams {\n summaryText: string;\n projectSlug: string;\n projectId: number | null;\n sessionId: string;\n gitLog?: string;\n model?: \"haiku\" | \"sonnet\" | \"opus\";\n /** Optional federation SQLite db — when provided, entities are upserted into kg_entities (QW1) */\n federationDb?: Database;\n /** Tenant ID for multi-tenant entity scoping (default: \"default\") */\n tenantId?: string;\n}\n\nexport interface ExtractTriplesResult {\n extracted: number;\n added: number;\n superseded: number;\n}\n\n/**\n * Extract structured KG triples from a session summary and store them in\n * Postgres. Idempotent: if a (subject, predicate) pair already has the same\n * object, no new row is added; if the object differs, the old triple is\n * invalidated (valid_to = NOW()) and a new one is inserted.\n *\n * Best-effort: per-triple errors are caught and logged but never thrown.\n * Returns a small stats object so callers can report progress.\n */\nexport async function extractAndStoreTriples(\n pool: Pool,\n params: ExtractTriplesParams\n): Promise<ExtractTriplesResult> {\n const stats: ExtractTriplesResult = { extracted: 0, added: 0, superseded: 0 };\n\n const prompt = buildTripleExtractionPrompt({\n sessionContent: params.summaryText,\n projectSlug: params.projectSlug,\n gitLog: params.gitLog ?? \"\",\n });\n\n const jsonOutput = await spawnClaude(prompt, params.model ?? \"sonnet\");\n if (!jsonOutput) return stats;\n\n // Strip markdown code fences if Claude wrapped the JSON\n const cleaned = jsonOutput\n .replace(/^```json\\s*/m, \"\")\n .replace(/^```\\s*/m, \"\")\n .replace(/\\s*```$/m, \"\")\n .trim();\n\n // Support both legacy array format and new structured format\n type LegacyTriple = { subject: string; predicate: string; object: string };\n type NewRelation = { source: string; relation: string; target: string };\n type NewEntity = { name: string; type: string; description: string };\n type NewFormat = { entities: NewEntity[]; relations: NewRelation[] };\n\n let triples: Array<LegacyTriple>;\n try {\n const parsed = JSON.parse(cleaned);\n\n if (Array.isArray(parsed)) {\n // Legacy format: [{subject, predicate, object}]\n triples = parsed;\n } else if (parsed && typeof parsed === \"object\" && Array.isArray(parsed.relations)) {\n // New structured format: {entities: [...], relations: [...]}\n const newFmt = parsed as NewFormat;\n\n // QW1: Upsert entities into federation SQLite kg_entities table when db is available\n if (params.federationDb && Array.isArray(newFmt.entities)) {\n const tenantId = params.tenantId ?? \"default\";\n for (const entity of newFmt.entities) {\n if (!entity.name) continue;\n try {\n upsertKgEntity(params.federationDb, {\n name: entity.name,\n type: entity.type ?? \"unknown\",\n description: entity.description,\n tenantId,\n });\n } catch (entityErr) {\n process.stderr.write(`[kg-extraction] entity upsert error (${entity.name}): ${entityErr}\\n`);\n }\n }\n }\n\n triples = newFmt.relations.map((r: NewRelation) => ({\n subject: r.source,\n predicate: r.relation,\n object: r.target,\n }));\n } else {\n process.stderr.write(`[kg-extraction] Unexpected JSON shape — neither array nor {entities,relations}\\n`);\n return stats;\n }\n } catch (e) {\n process.stderr.write(`[kg-extraction] JSON parse failed: ${e}\\n`);\n return stats;\n }\n\n if (!Array.isArray(triples)) return stats;\n stats.extracted = triples.length;\n\n for (const t of triples) {\n if (!t.subject || !t.predicate || !t.object) continue;\n\n try {\n const existing = await kgQuery(pool, {\n subject: t.subject,\n predicate: t.predicate,\n project_id: params.projectId ?? undefined,\n });\n\n // If an identical (subject, predicate, object) is already valid, skip — idempotent\n const alreadyValid = existing.find((e) => e.object === t.object && !e.valid_to);\n if (alreadyValid) continue;\n\n // Invalidate any superseded triple (same subject+predicate, different object)\n const supersedes = existing.find((e) => e.object !== t.object && !e.valid_to);\n if (supersedes) {\n await kgInvalidate(pool, supersedes.id);\n stats.superseded++;\n }\n\n await kgAdd(pool, {\n subject: t.subject,\n predicate: t.predicate,\n object: t.object,\n project_id: params.projectId ?? undefined,\n source_session: params.sessionId,\n confidence: \"EXTRACTED\",\n });\n stats.added++;\n } catch (tripleErr) {\n process.stderr.write(`[kg-extraction] store error (${t.subject}): ${tripleErr}\\n`);\n }\n }\n\n return stats;\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"pai-marker-HVBwwBW-.mjs","names":[],"sources":["../src/registry/pai-marker.ts"],"sourcesContent":["/**\n * PAI.md marker file management.\n *\n * Each registered project gets a `Notes/PAI.md` file with a YAML frontmatter\n * `pai:` block that PAI manages. The rest of the file (body content, other\n * frontmatter keys) is user-owned and never modified by PAI.\n *\n * YAML parsing/updating is done with simple regex — no external dependency.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n mkdirSync,\n readdirSync,\n statSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PaiMarker {\n /** Absolute path to the PAI.md file */\n path: string;\n /** The `slug` value from the `pai:` frontmatter block */\n slug: string;\n /** Absolute path to the project root (parent of Notes/) */\n projectRoot: string;\n}\n\n// ---------------------------------------------------------------------------\n// Template content (mirrors templates/pai-project.template.md)\n// ---------------------------------------------------------------------------\n\nconst TEMPLATE = `---\npai:\n slug: \"\\${SLUG}\"\n registered: \"\\${DATE}\"\n last_indexed: null\n status: active\n---\n\n# \\${DISPLAY_NAME}\n\n<!-- Everything below the YAML frontmatter is yours — PAI never modifies content here. -->\n<!-- Use this file for project notes, decisions, preferences, or anything you want. -->\n<!-- PAI only reads and updates the \\`pai:\\` block in the frontmatter above. -->\n`;\n\nfunction isoDate(): string {\n return new Date().toISOString().slice(0, 10);\n}\n\nfunction renderTemplate(slug: string, displayName: string): string {\n return TEMPLATE.replace(/\\$\\{SLUG\\}/g, slug)\n .replace(/\\$\\{DATE\\}/g, isoDate())\n .replace(/\\$\\{DISPLAY_NAME\\}/g, displayName);\n}\n\n// ---------------------------------------------------------------------------\n// YAML frontmatter helpers (regex-based, no external dependency)\n// ---------------------------------------------------------------------------\n\n/**\n * Split a markdown file with YAML frontmatter into its parts.\n *\n * Returns { frontmatter, body } where:\n * frontmatter — content between the opening and closing `---` delimiters\n * body — everything after the closing `---` line\n *\n * Returns null if the file does not begin with a `---` frontmatter block.\n */\nfunction parseFrontmatter(\n content: string\n): { frontmatter: string; body: string } | null {\n if (!content.startsWith(\"---\")) return null;\n\n // Skip past the opening \"---\" and its line ending\n const afterOpen = content.slice(3);\n const eolMatch = afterOpen.match(/^\\r?\\n/);\n if (!eolMatch) return null;\n\n const rest = afterOpen.slice(eolMatch[0].length);\n\n // Find closing \"---\" on its own line\n const closeMatch = rest.match(/^([\\s\\S]*?)\\n---[ \\t]*(\\r?\\n|$)/m);\n if (!closeMatch) return null;\n\n const frontmatter = closeMatch[1];\n const body = rest.slice(closeMatch[0].length);\n\n return { frontmatter, body };\n}\n\n/**\n * Extract a simple scalar YAML value from a block of YAML text.\n *\n * extractYamlValue(' slug: \"my-proj\"', \"slug\") → \"my-proj\"\n * extractYamlValue(' slug: my-proj', \"slug\") → \"my-proj\"\n * extractYamlValue(' last_indexed: null', \"last_indexed\") → \"null\"\n */\nfunction extractYamlValue(yamlBlock: string, key: string): string | null {\n const re = new RegExp(\n `^[ \\\\t]*${key}[ \\\\t]*:[ \\\\t]*\"?([^\"\\\\n\\\\r]*?)\"?[ \\\\t]*$`,\n \"m\"\n );\n const match = yamlBlock.match(re);\n if (!match) return null;\n return match[1].trim() || null;\n}\n\n/**\n * Replace the `pai:` mapping block inside a frontmatter string with\n * `newPaiBlock`. If no `pai:` block is found, appends it at the end.\n *\n * The regex captures the `pai:` key and all immediately-following indented\n * lines (the mapping values), then replaces the whole group.\n *\n * Edge case: the last indented line may not have a trailing newline when it\n * is the final line of the frontmatter string. We handle this by matching\n * lines that end with \\n OR with end-of-string.\n */\nfunction replacePaiBlock(frontmatter: string, newPaiBlock: string): string {\n // Normalise: ensure the frontmatter string ends with \\n so the regex\n // always finds a clean boundary after the last indented line.\n const fm = frontmatter.endsWith(\"\\n\") ? frontmatter : frontmatter + \"\\n\";\n\n // Match \"pai:\\n\" followed by any number of indented lines (each ending \\n).\n const paiRe = /^pai:[ \\t]*\\r?\\n(?:[ \\t]+[^\\r\\n]*\\r?\\n)*/m;\n if (paiRe.test(fm)) {\n // Replace and strip the extra trailing \\n we may have added.\n const replaced = fm.replace(paiRe, newPaiBlock);\n return frontmatter.endsWith(\"\\n\") ? replaced : replaced.replace(/\\n$/, \"\");\n }\n // pai: key not found — append it\n return fm + newPaiBlock;\n}\n\n/**\n * Build the canonical `pai:` YAML block (with a trailing newline).\n */\nfunction buildPaiBlock(\n slug: string,\n registered: string,\n lastIndexed: string | null,\n status: string\n): string {\n const lastIndexedStr =\n lastIndexed === null ? \"null\" : `\"${lastIndexed}\"`;\n return (\n `pai:\\n` +\n ` slug: \"${slug}\"\\n` +\n ` registered: \"${registered}\"\\n` +\n ` last_indexed: ${lastIndexedStr}\\n` +\n ` status: ${status}\\n`\n );\n}\n\n// ---------------------------------------------------------------------------\n// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Create or update `<projectRoot>/Notes/PAI.md`.\n *\n * - File absent: creates `Notes/` if needed, writes from template.\n * - File present: updates only the `pai:` frontmatter block; body and all\n * other frontmatter keys are preserved verbatim.\n *\n * @param projectRoot Absolute path to the project root directory.\n * @param slug PAI slug for this project.\n * @param displayName Human-readable name (defaults to slug if omitted).\n */\nexport function ensurePaiMarker(\n projectRoot: string,\n slug: string,\n displayName?: string\n): void {\n const notesDir = join(projectRoot, \"Notes\");\n const markerPath = join(notesDir, \"PAI.md\");\n const name = displayName ?? slug;\n\n // --- File does not exist — create from template ---\n if (!existsSync(markerPath)) {\n mkdirSync(notesDir, { recursive: true });\n writeFileSync(markerPath, renderTemplate(slug, name), \"utf8\");\n return;\n }\n\n // --- File exists — update only the `pai:` block ---\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n\n if (!parsed) {\n // No YAML frontmatter — prepend a fresh one, treat the whole file as body.\n const paiBlock = buildPaiBlock(slug, isoDate(), null, \"active\");\n const newContent = `---\\n${paiBlock}---\\n\\n${raw}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n return;\n }\n\n const { frontmatter, body } = parsed;\n\n // Preserve existing `registered` date so we don't reset it on re-scan.\n const existingRegistered =\n extractYamlValue(frontmatter, \"registered\") ?? isoDate();\n\n // Preserve existing `last_indexed` value (may be \"null\" string or a date).\n const rawLastIndexed = extractYamlValue(frontmatter, \"last_indexed\");\n const lastIndexed =\n rawLastIndexed === null || rawLastIndexed === \"null\"\n ? null\n : rawLastIndexed;\n\n // Preserve existing `status`.\n const existingStatus = extractYamlValue(frontmatter, \"status\") ?? \"active\";\n\n const newPaiBlock = buildPaiBlock(\n slug,\n existingRegistered,\n lastIndexed,\n existingStatus\n );\n\n const newFrontmatter = replacePaiBlock(frontmatter, newPaiBlock);\n\n // Ensure the frontmatter block ends with exactly one newline before the\n // closing --- delimiter.\n const fmWithNewline = newFrontmatter.endsWith(\"\\n\")\n ? newFrontmatter\n : newFrontmatter + \"\\n\";\n\n // Reconstruct the full file. Preserve whatever separator the body has.\n const newContent = `---\\n${fmWithNewline}---\\n${body}`;\n writeFileSync(markerPath, newContent, \"utf8\");\n}\n\n/**\n * Read PAI marker data from `<projectRoot>/Notes/PAI.md`.\n * Returns null if the file does not exist or contains no `pai:` block.\n */\nexport function readPaiMarker(\n projectRoot: string\n): { slug: string; registered: string; status: string } | null {\n const markerPath = join(projectRoot, \"Notes\", \"PAI.md\");\n if (!existsSync(markerPath)) return null;\n\n const raw = readFileSync(markerPath, \"utf8\");\n const parsed = parseFrontmatter(raw);\n if (!parsed) return null;\n\n const slug = extractYamlValue(parsed.frontmatter, \"slug\");\n if (!slug) return null;\n\n const registered =\n extractYamlValue(parsed.frontmatter, \"registered\") ?? \"\";\n const status =\n extractYamlValue(parsed.frontmatter, \"status\") ?? \"active\";\n\n return { slug, registered, status };\n}\n\n/**\n * Scan a list of parent directories for `<child>/Notes/PAI.md` marker files.\n * Each directory in `searchDirs` is scanned one level deep — its immediate\n * child directories are checked for a `Notes/PAI.md` file.\n *\n * Returns an array of PaiMarker objects for every valid marker found.\n * Invalid or malformed markers are silently skipped.\n *\n * @param searchDirs Absolute paths to parent directories.\n */\nexport function discoverPaiMarkers(searchDirs: string[]): PaiMarker[] {\n const results: PaiMarker[] = [];\n\n for (const dir of searchDirs) {\n if (!existsSync(dir)) continue;\n\n let children: string[];\n try {\n children = readdirSync(dir);\n } catch {\n continue;\n }\n\n for (const child of children) {\n if (child.startsWith(\".\")) continue;\n const childPath = join(dir, child);\n try {\n if (!statSync(childPath).isDirectory()) continue;\n } catch {\n continue;\n }\n\n const markerData = readPaiMarker(childPath);\n if (!markerData) continue;\n\n results.push({\n path: join(childPath, \"Notes\", \"PAI.md\"),\n slug: markerData.slug,\n projectRoot: childPath,\n });\n }\n }\n\n return results;\n}\n"],"mappings":"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"}