@tekmidian/pai 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/README.md +19 -0
  3. package/dist/{aibroker-client-BYG-GtAg.mjs → aibroker-client-BpfuGPe5.mjs} +1 -1
  4. package/dist/{aibroker-client-BK1h--W_.mjs → aibroker-client-DRld7g6z.mjs} +2 -2
  5. package/dist/{aibroker-client-BK1h--W_.mjs.map → aibroker-client-DRld7g6z.mjs.map} +1 -1
  6. package/dist/{async-DNVWtQl2.mjs → async-CY_cd_8j.mjs} +5 -4
  7. package/dist/async-CY_cd_8j.mjs.map +1 -0
  8. package/dist/{auto-route-CF8WNaoy.mjs → auto-route-CPlam4iv.mjs} +2 -2
  9. package/dist/{auto-route-CF8WNaoy.mjs.map → auto-route-CPlam4iv.mjs.map} +1 -1
  10. package/dist/{chain-CuaDawK9.mjs → chain-o-g8AgE3.mjs} +4 -4
  11. package/dist/{chain-CuaDawK9.mjs.map → chain-o-g8AgE3.mjs.map} +1 -1
  12. package/dist/chunker-BH4i-F2b.mjs +300 -0
  13. package/dist/chunker-BH4i-F2b.mjs.map +1 -0
  14. package/dist/cli/index.mjs +20 -20
  15. package/dist/cli/program.mjs +20 -20
  16. package/dist/{clusters-Ci1mseh2.mjs → clusters-DsMf20PP.mjs} +2 -2
  17. package/dist/{clusters-Ci1mseh2.mjs.map → clusters-DsMf20PP.mjs.map} +1 -1
  18. package/dist/{config-CQCmH_gV.mjs → config-2wkz744W.mjs} +3 -3
  19. package/dist/{config-CQCmH_gV.mjs.map → config-2wkz744W.mjs.map} +1 -1
  20. package/dist/{config-DjXq_W42.mjs → config-CUTg6VDq.mjs} +1 -1
  21. package/dist/{context-handover-cache-DFNnlWv2.mjs → context-handover-cache-BOpjLsKe.mjs} +6 -6
  22. package/dist/{context-handover-cache-DFNnlWv2.mjs.map → context-handover-cache-BOpjLsKe.mjs.map} +1 -1
  23. package/dist/daemon/index.mjs +16 -16
  24. package/dist/{daemon-Bf7Eock4.mjs → daemon-BYieXduQ.mjs} +31 -30
  25. package/dist/{daemon-Bf7Eock4.mjs.map → daemon-BYieXduQ.mjs.map} +1 -1
  26. package/dist/daemon-mcp/index.mjs +20 -11
  27. package/dist/daemon-mcp/index.mjs.map +1 -1
  28. package/dist/daemon-q3xjHwa4.mjs +19 -0
  29. package/dist/{detector-3g7XuU1r.mjs → detector-BAlmrLQ0.mjs} +2 -2
  30. package/dist/{detector-3g7XuU1r.mjs.map → detector-BAlmrLQ0.mjs.map} +1 -1
  31. package/dist/detector-C-YmZsOi.mjs +3 -0
  32. package/dist/{embeddings-DfrYNIng.mjs → embeddings-DK9XfQic.mjs} +1 -1
  33. package/dist/{embeddings-DfrYNIng.mjs.map → embeddings-DK9XfQic.mjs.map} +1 -1
  34. package/dist/{embeddings-BNv3TQC5.mjs → embeddings-Xg8XLf2K.mjs} +1 -1
  35. package/dist/{factory-UO9_DFMN.mjs → factory-BMK0tC1b.mjs} +1 -1
  36. package/dist/{factory-BozZa81q.mjs → factory-C4We3xR_.mjs} +13 -13
  37. package/dist/{factory-BozZa81q.mjs.map → factory-C4We3xR_.mjs.map} +1 -1
  38. package/dist/{fallback-F0TWGVM2.mjs → fallback-ECiqCuh6.mjs} +5 -5
  39. package/dist/{fallback-F0TWGVM2.mjs.map → fallback-ECiqCuh6.mjs.map} +1 -1
  40. package/dist/federation-db-BZ8PyxFe.mjs +3 -0
  41. package/dist/{federation-db-w8e-sKjp.mjs → federation-db-CUgy0WYs.mjs} +2 -2
  42. package/dist/{federation-db-w8e-sKjp.mjs.map → federation-db-CUgy0WYs.mjs.map} +1 -1
  43. package/dist/{helpers-Dj-B314s.mjs → helpers-BRCJg0G3.mjs} +2 -208
  44. package/dist/helpers-BRCJg0G3.mjs.map +1 -0
  45. package/dist/hooks/load-project-context.mjs +6 -0
  46. package/dist/hooks/load-project-context.mjs.map +2 -2
  47. package/dist/index.d.mts +26 -8
  48. package/dist/index.d.mts.map +1 -1
  49. package/dist/index.mjs +5 -4
  50. package/dist/indexer-backend-CJ0RGgMz.mjs +5 -0
  51. package/dist/{ipc-client-Dasm515c.mjs → ipc-client-EhWY8XL0.mjs} +2 -2
  52. package/dist/{ipc-client-Dasm515c.mjs.map → ipc-client-EhWY8XL0.mjs.map} +1 -1
  53. package/dist/{kg-entity-Cj8Q6YmU.mjs → kg-entity-1uqCnk4u.mjs} +1 -1
  54. package/dist/{kg-entity-Cj8Q6YmU.mjs.map → kg-entity-1uqCnk4u.mjs.map} +1 -1
  55. package/dist/{latent-ideas-BRy6HNwg.mjs → latent-ideas-CSuKfiq3.mjs} +4 -4
  56. package/dist/{latent-ideas-BRy6HNwg.mjs.map → latent-ideas-CSuKfiq3.mjs.map} +1 -1
  57. package/dist/{link-boost-BptbAPuv.mjs → link-boost-BtjzfE3c.mjs} +1 -1
  58. package/dist/{link-boost-BptbAPuv.mjs.map → link-boost-BtjzfE3c.mjs.map} +1 -1
  59. package/dist/{main-resolver-BIft_FVF.mjs → main-resolver-DyOrwNYw.mjs} +6 -6
  60. package/dist/{main-resolver-BIft_FVF.mjs.map → main-resolver-DyOrwNYw.mjs.map} +1 -1
  61. package/dist/main-resolver-vN09bkXv.mjs +7 -0
  62. package/dist/merge-CXGz5wXz.mjs +3 -0
  63. package/dist/{merge-xoyfZQ4W.mjs → merge-D58n_-LZ.mjs} +1 -1
  64. package/dist/{merge-xoyfZQ4W.mjs.map → merge-D58n_-LZ.mjs.map} +1 -1
  65. package/dist/{neighborhood-D8uscnGy.mjs → neighborhood-DNuRelRB.mjs} +2 -2
  66. package/dist/{neighborhood-D8uscnGy.mjs.map → neighborhood-DNuRelRB.mjs.map} +1 -1
  67. package/dist/{note-context-CgffaUMh.mjs → note-context-D9JZ4-7o.mjs} +1 -1
  68. package/dist/{note-context-CgffaUMh.mjs.map → note-context-D9JZ4-7o.mjs.map} +1 -1
  69. package/dist/{pai-home-CeOIYltV.mjs → pai-home-UncxWxlX.mjs} +1 -1
  70. package/dist/{pai-home-CeOIYltV.mjs.map → pai-home-UncxWxlX.mjs.map} +1 -1
  71. package/dist/{planner-BG9ivPj0.mjs → planner-DgC3oSvb.mjs} +7 -7
  72. package/dist/{planner-BG9ivPj0.mjs.map → planner-DgC3oSvb.mjs.map} +1 -1
  73. package/dist/{postgres-CxD6_Dca.mjs → postgres-CcsRKir-.mjs} +6 -5
  74. package/dist/{postgres-CxD6_Dca.mjs.map → postgres-CcsRKir-.mjs.map} +1 -1
  75. package/dist/{program-BRvCzhjx.mjs → program-JhF2GgJY.mjs} +87 -74
  76. package/dist/program-JhF2GgJY.mjs.map +1 -0
  77. package/dist/query-feedback-BPHFFSu1.mjs +3 -0
  78. package/dist/{query-feedback-xz5axTj3.mjs → query-feedback-BPa0dISE.mjs} +2 -2
  79. package/dist/{query-feedback-xz5axTj3.mjs.map → query-feedback-BPa0dISE.mjs.map} +1 -1
  80. package/dist/{registry-db-Bkpbp7Gf.mjs → registry-db-DCzdI4sC.mjs} +2 -2
  81. package/dist/{registry-db-Bkpbp7Gf.mjs.map → registry-db-DCzdI4sC.mjs.map} +1 -1
  82. package/dist/registry-db-Hphlj3H4.mjs +3 -0
  83. package/dist/{registry-postgres-B07-hmn8.mjs → registry-postgres-BUnkHKYs.mjs} +2 -2
  84. package/dist/{registry-postgres-B07-hmn8.mjs.map → registry-postgres-BUnkHKYs.mjs.map} +1 -1
  85. package/dist/{registry-sqlite-CkGND_tm.mjs → registry-sqlite-CKYNwkUv.mjs} +2 -2
  86. package/dist/{registry-sqlite-CkGND_tm.mjs.map → registry-sqlite-CKYNwkUv.mjs.map} +1 -1
  87. package/dist/{reranker-BS5gqGeF.mjs → reranker-CFiEzHQu.mjs} +1 -1
  88. package/dist/{reranker-CRVawoEh.mjs → reranker-DKv80KO5.mjs} +1 -1
  89. package/dist/{reranker-CRVawoEh.mjs.map → reranker-DKv80KO5.mjs.map} +1 -1
  90. package/dist/{router-DjFZIoDl.mjs → router-CGZATlvm.mjs} +1 -1
  91. package/dist/{router-DjFZIoDl.mjs.map → router-CGZATlvm.mjs.map} +1 -1
  92. package/dist/router-CoA8Uy1m.mjs +3 -0
  93. package/dist/{run-BVQZI79D.mjs → run-W2rV_9j0.mjs} +78 -54
  94. package/dist/run-W2rV_9j0.mjs.map +1 -0
  95. package/dist/{run-env-BE5xyCwy.mjs → run-env-Br4Bc7Vz.mjs} +3 -3
  96. package/dist/{run-env-BE5xyCwy.mjs.map → run-env-Br4Bc7Vz.mjs.map} +1 -1
  97. package/dist/{runtime-paths-BRwDeBEg.mjs → runtime-paths-QGQJAekd.mjs} +1 -1
  98. package/dist/{runtime-paths-BRwDeBEg.mjs.map → runtime-paths-QGQJAekd.mjs.map} +1 -1
  99. package/dist/{search-BaTbyWjK.mjs → search-BOSphCJ1.mjs} +2 -2
  100. package/dist/{search-BaTbyWjK.mjs.map → search-BOSphCJ1.mjs.map} +1 -1
  101. package/dist/search-Bf3Kub3F.mjs +3 -0
  102. package/dist/{server-BY6AedmC.mjs → server-B71rem4q.mjs} +3 -3
  103. package/dist/{server-BY6AedmC.mjs.map → server-B71rem4q.mjs.map} +1 -1
  104. package/dist/{session-keepalive-DJA97K2X.mjs → session-keepalive-Dgil9hjw.mjs} +7 -7
  105. package/dist/{session-keepalive-DJA97K2X.mjs.map → session-keepalive-Dgil9hjw.mjs.map} +1 -1
  106. package/dist/{sources-BBe3AZ-W.mjs → sources-LcGptLA-.mjs} +1 -1
  107. package/dist/{sources-BBe3AZ-W.mjs.map → sources-LcGptLA-.mjs.map} +1 -1
  108. package/dist/{sqlite-CfKfuggn.mjs → sqlite-CAJcw2zL.mjs} +8 -7
  109. package/dist/sqlite-CAJcw2zL.mjs.map +1 -0
  110. package/dist/{state-n13uPK5B.mjs → state-CHltNjXI.mjs} +1 -1
  111. package/dist/{state-n13uPK5B.mjs.map → state-CHltNjXI.mjs.map} +1 -1
  112. package/dist/{state-CwlGAKgF.mjs → state-S9wlKarB.mjs} +1 -1
  113. package/dist/{stop-words-ClCwO8mZ.mjs → stop-words-BdQuaE9K.mjs} +1 -1
  114. package/dist/{stop-words-ClCwO8mZ.mjs.map → stop-words-BdQuaE9K.mjs.map} +1 -1
  115. package/dist/{themes-B2v7RNnA.mjs → themes-D1FQFthd.mjs} +3 -3
  116. package/dist/{themes-B2v7RNnA.mjs.map → themes-D1FQFthd.mjs.map} +1 -1
  117. package/dist/{tools-DFdPbX-X.mjs → tools-BFC-113F.mjs} +84 -68
  118. package/dist/tools-BFC-113F.mjs.map +1 -0
  119. package/dist/tools-BW7OXf-N.mjs +6 -0
  120. package/dist/{trace-BRflVyBi.mjs → trace-IDKK1VFs.mjs} +1 -1
  121. package/dist/{trace-BRflVyBi.mjs.map → trace-IDKK1VFs.mjs.map} +1 -1
  122. package/dist/{vault-indexer-mCFus_AM.mjs → vault-indexer-y6YC2-mh.mjs} +4 -3
  123. package/dist/vault-indexer-y6YC2-mh.mjs.map +1 -0
  124. package/dist/{wakeup-DO1O4udw.mjs → wakeup-CNZQZzsY.mjs} +3 -3
  125. package/dist/{wakeup-DO1O4udw.mjs.map → wakeup-CNZQZzsY.mjs.map} +1 -1
  126. package/dist/work-queue-worker-CkShUa0z.mjs +11 -0
  127. package/dist/{work-queue-worker-BbrC3I_R.mjs → work-queue-worker-Dy5FT2ak.mjs} +4 -4
  128. package/dist/{work-queue-worker-BbrC3I_R.mjs.map → work-queue-worker-Dy5FT2ak.mjs.map} +1 -1
  129. package/dist/{zettelkasten-BPxSOalU.mjs → zettelkasten-Dx-63BEk.mjs} +4 -4
  130. package/dist/{zettelkasten-BPxSOalU.mjs.map → zettelkasten-Dx-63BEk.mjs.map} +1 -1
  131. package/docs/commands/README.md +2 -0
  132. package/docs/commands/project.md +14 -5
  133. package/docs/commands/projects.md +14 -5
  134. package/gemini-extension.json +1 -1
  135. package/package.json +1 -1
  136. package/pai-plugin.json +1 -0
  137. package/src/hooks/ts/session-start/load-project-context.ts +9 -0
  138. package/dist/async-DNVWtQl2.mjs.map +0 -1
  139. package/dist/daemon-bJi1gDkG.mjs +0 -19
  140. package/dist/detector-DgDn56j-.mjs +0 -3
  141. package/dist/federation-db-CJDw_Pwq.mjs +0 -3
  142. package/dist/helpers-Dj-B314s.mjs.map +0 -1
  143. package/dist/indexer-backend-BN9rpp9O.mjs +0 -5
  144. package/dist/main-resolver-C9-poMdi.mjs +0 -7
  145. package/dist/merge-BncjJTfb.mjs +0 -3
  146. package/dist/program-BRvCzhjx.mjs.map +0 -1
  147. package/dist/query-feedback-COYCnvHQ.mjs +0 -3
  148. package/dist/registry-db-DwXpf7yF.mjs +0 -3
  149. package/dist/router-C5hPk9YP.mjs +0 -3
  150. package/dist/run-BVQZI79D.mjs.map +0 -1
  151. package/dist/search-BQHL5wDr.mjs +0 -3
  152. package/dist/sqlite-CfKfuggn.mjs.map +0 -1
  153. package/dist/tools-B52IvaU6.mjs +0 -6
  154. package/dist/tools-DFdPbX-X.mjs.map +0 -1
  155. package/dist/vault-indexer-mCFus_AM.mjs.map +0 -1
  156. package/dist/work-queue-worker-z-AATZ2t.mjs +0 -11
  157. /package/dist/{indexer-backend-Cc-_NK3q.mjs → indexer-backend-DotpxHJd.mjs} +0 -0
@@ -47,4 +47,4 @@ function schedulerLogPath() {
47
47
 
48
48
  //#endregion
49
49
  export { schedulerLogPath as a, paiSocketPath as i, daemonLogPath as n, daemonPidPath as r, aibrokerSocketPath as t };
50
- //# sourceMappingURL=runtime-paths-BRwDeBEg.mjs.map
50
+ //# sourceMappingURL=runtime-paths-QGQJAekd.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-paths-BRwDeBEg.mjs","names":[],"sources":["../src/runtime-paths.ts"],"sourcesContent":["/**\n * runtime-paths.ts — the shared files PAI talks to the running daemon through.\n *\n * Sockets, logs and pidfiles live at fixed absolute paths under /tmp, and every\n * one of them is shared with a *live* daemon. That makes them the same hazard\n * as any other piece of real user state a test can reach: writing one from a\n * test does not fail, it corrupts something already running, silently.\n *\n * The home guard in test/setup-home-guard.ts does not cover them. It works by\n * redirecting HOME, so it protects paths *derived* from the home directory and\n * nothing else — a hardcoded \"/tmp/pai.sock\" walks straight past it. Verified\n * on 2026-08-01 by snapshotting all five and running the suite: nothing writes\n * to them today. But \"no test does this yet\" is not a property, and the next\n * daemon test to be written would clobber the live socket with nothing to stop\n * it.\n *\n * So each path is read from an environment variable with the historical /tmp\n * value as its default. Behaviour is unchanged for every real invocation; the\n * test setup overrides the variables and the whole class becomes unreachable\n * rather than merely absent, the same way the home guard works.\n *\n * Read at call time, not at module load: the test setup file runs before any\n * module is imported, but resolving these eagerly would still bake in whatever\n * the environment held at import and make the override order matter.\n */\n\n/** IPC socket for the PAI daemon. */\nexport function paiSocketPath(): string {\n return process.env.PAI_SOCKET_PATH ?? \"/tmp/pai.sock\";\n}\n\n/** IPC socket for the AIBroker daemon — read by PAI, owned by AIBroker. */\nexport function aibrokerSocketPath(): string {\n return process.env.PAI_AIBROKER_SOCKET_PATH ?? \"/tmp/aibroker.sock\";\n}\n\n/** Where the daemon's stdout and stderr are collected. */\nexport function daemonLogPath(): string {\n return process.env.PAI_DAEMON_LOG_PATH ?? \"/tmp/pai-daemon.log\";\n}\n\n/** Pidfile for the running daemon. */\nexport function daemonPidPath(): string {\n return process.env.PAI_DAEMON_PID_PATH ?? \"/tmp/pai-daemon.pid\";\n}\n\n/** Where the launchd scheduler tick writes its output. */\nexport function schedulerLogPath(): string {\n return process.env.PAI_SCHEDULER_LOG_PATH ?? \"/tmp/pai-scheduler.log\";\n}\n\n/**\n * Where the status line caches what it may reuse between renders: the\n * per-provider plan-usage snapshots and the Claude Code version. Shared with\n * every live session's status line, so a test writing here would be feeding\n * fixtures to the real thing. Read by statusline-command.sh as PAI_CACHE_DIR.\n */\nexport function paiCacheDir(): string {\n return process.env.PAI_CACHE_DIR ?? \"/tmp/claude\";\n}\n\n/**\n * Every runtime path, for the test guard to redirect in one place.\n *\n * Listed here rather than in the guard so that adding a path and forgetting to\n * protect it is not possible: the guard iterates this.\n */\nexport const RUNTIME_PATH_ENV_VARS = [\n \"PAI_SOCKET_PATH\",\n \"PAI_AIBROKER_SOCKET_PATH\",\n \"PAI_DAEMON_LOG_PATH\",\n \"PAI_DAEMON_PID_PATH\",\n \"PAI_SCHEDULER_LOG_PATH\",\n \"PAI_CACHE_DIR\",\n] as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,mBAAmB;;;AAIxC,SAAgB,qBAA6B;AAC3C,QAAO,QAAQ,IAAI,4BAA4B;;;AAIjD,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,uBAAuB;;;AAI5C,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,uBAAuB;;;AAI5C,SAAgB,mBAA2B;AACzC,QAAO,QAAQ,IAAI,0BAA0B"}
1
+ {"version":3,"file":"runtime-paths-QGQJAekd.mjs","names":[],"sources":["../src/runtime-paths.ts"],"sourcesContent":["/**\n * runtime-paths.ts — the shared files PAI talks to the running daemon through.\n *\n * Sockets, logs and pidfiles live at fixed absolute paths under /tmp, and every\n * one of them is shared with a *live* daemon. That makes them the same hazard\n * as any other piece of real user state a test can reach: writing one from a\n * test does not fail, it corrupts something already running, silently.\n *\n * The home guard in test/setup-home-guard.ts does not cover them. It works by\n * redirecting HOME, so it protects paths *derived* from the home directory and\n * nothing else — a hardcoded \"/tmp/pai.sock\" walks straight past it. Verified\n * on 2026-08-01 by snapshotting all five and running the suite: nothing writes\n * to them today. But \"no test does this yet\" is not a property, and the next\n * daemon test to be written would clobber the live socket with nothing to stop\n * it.\n *\n * So each path is read from an environment variable with the historical /tmp\n * value as its default. Behaviour is unchanged for every real invocation; the\n * test setup overrides the variables and the whole class becomes unreachable\n * rather than merely absent, the same way the home guard works.\n *\n * Read at call time, not at module load: the test setup file runs before any\n * module is imported, but resolving these eagerly would still bake in whatever\n * the environment held at import and make the override order matter.\n */\n\n/** IPC socket for the PAI daemon. */\nexport function paiSocketPath(): string {\n return process.env.PAI_SOCKET_PATH ?? \"/tmp/pai.sock\";\n}\n\n/** IPC socket for the AIBroker daemon — read by PAI, owned by AIBroker. */\nexport function aibrokerSocketPath(): string {\n return process.env.PAI_AIBROKER_SOCKET_PATH ?? \"/tmp/aibroker.sock\";\n}\n\n/** Where the daemon's stdout and stderr are collected. */\nexport function daemonLogPath(): string {\n return process.env.PAI_DAEMON_LOG_PATH ?? \"/tmp/pai-daemon.log\";\n}\n\n/** Pidfile for the running daemon. */\nexport function daemonPidPath(): string {\n return process.env.PAI_DAEMON_PID_PATH ?? \"/tmp/pai-daemon.pid\";\n}\n\n/** Where the launchd scheduler tick writes its output. */\nexport function schedulerLogPath(): string {\n return process.env.PAI_SCHEDULER_LOG_PATH ?? \"/tmp/pai-scheduler.log\";\n}\n\n/**\n * Where the status line caches what it may reuse between renders: the\n * per-provider plan-usage snapshots and the Claude Code version. Shared with\n * every live session's status line, so a test writing here would be feeding\n * fixtures to the real thing. Read by statusline-command.sh as PAI_CACHE_DIR.\n */\nexport function paiCacheDir(): string {\n return process.env.PAI_CACHE_DIR ?? \"/tmp/claude\";\n}\n\n/**\n * Every runtime path, for the test guard to redirect in one place.\n *\n * Listed here rather than in the guard so that adding a path and forgetting to\n * protect it is not possible: the guard iterates this.\n */\nexport const RUNTIME_PATH_ENV_VARS = [\n \"PAI_SOCKET_PATH\",\n \"PAI_AIBROKER_SOCKET_PATH\",\n \"PAI_DAEMON_LOG_PATH\",\n \"PAI_DAEMON_PID_PATH\",\n \"PAI_SCHEDULER_LOG_PATH\",\n \"PAI_CACHE_DIR\",\n] as const;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,mBAAmB;;;AAIxC,SAAgB,qBAA6B;AAC3C,QAAO,QAAQ,IAAI,4BAA4B;;;AAIjD,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,uBAAuB;;;AAI5C,SAAgB,gBAAwB;AACtC,QAAO,QAAQ,IAAI,uBAAuB;;;AAI5C,SAAgB,mBAA2B;AACzC,QAAO,QAAQ,IAAI,0BAA0B"}
@@ -1,4 +1,4 @@
1
- import { t as STOP_WORDS } from "./stop-words-ClCwO8mZ.mjs";
1
+ import { t as STOP_WORDS } from "./stop-words-BdQuaE9K.mjs";
2
2
 
3
3
  //#region src/memory/search.ts
4
4
  /**
@@ -104,4 +104,4 @@ function applyRecencyBoost(results, halfLifeDays = 90) {
104
104
 
105
105
  //#endregion
106
106
  export { populateSlugs as i, buildFtsQuery as n, isQuerySyntaxError as r, applyRecencyBoost as t };
107
- //# sourceMappingURL=search-BaTbyWjK.mjs.map
107
+ //# sourceMappingURL=search-BOSphCJ1.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"search-BaTbyWjK.mjs","names":[],"sources":["../src/memory/search.ts"],"sourcesContent":["/**\n * Query building and result post-processing for the PAI federation memory\n * index — everything here is pure logic (no SQL). The raw FTS5/vector\n * queries live under src/storage/{sqlite,postgres}/search.ts, which call\n * back into buildFtsQuery()/isQuerySyntaxError() from this module.\n */\n\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SearchResult {\n projectId: number;\n projectSlug?: string; // populated from registry after search when available\n path: string;\n startLine: number;\n endLine: number;\n snippet: string;\n score: number; // raw BM25 score (lower = more relevant in FTS5)\n tier: string;\n source: string;\n updatedAt?: number; // Unix ms from memory_chunks.updated_at\n lastAccessedAt?: number; // Unix ms from memory_chunks.last_accessed_at (QW2)\n chunkId?: string; // chunk ID for last_accessed_at update (QW2)\n}\n\nexport interface SearchOptions {\n /** Restrict search to these project IDs. */\n projectIds?: number[];\n /** Restrict to 'memory' or 'notes' sources. */\n sources?: string[];\n /** Restrict to specific tier(s): 'evergreen' | 'daily' | 'topic' | 'session' */\n tiers?: string[];\n /** Maximum number of results to return. Default 10. */\n maxResults?: number;\n /** Minimum BM25 score threshold (FTS5 scores are negative; 0.0 means no filter). */\n minScore?: number;\n}\n\n// STOP_WORDS imported from utils/stop-words.ts\n\n// ---------------------------------------------------------------------------\n// Query builder\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a free-text query into an FTS5 query string.\n *\n * Strategy:\n * 1. Tokenise by whitespace and punctuation\n * 2. Remove stop words and tokens shorter than 2 characters\n * 3. Double-quote each remaining token (exact word form)\n * 4. Join with OR so that any matching token returns a result\n *\n * Using OR instead of AND is critical for multi-word queries: the words rarely\n * all appear in the same chunk, so AND would return zero results. FTS5 BM25\n * scoring naturally ranks chunks where more terms match higher, so the most\n * relevant chunks still surface at the top.\n *\n * Example: \"Synchrotech interview follow-up Gilles\"\n * → `\"synchrotech\" OR \"interview\" OR \"follow\" OR \"gilles\"`\n * → chunks matching any term, ranked by how many terms match\n */\n/**\n * Did SQLite reject the QUERY, or fail at the STORE?\n *\n * Only the first justifies an empty result. FTS5 reports a bad MATCH expression\n * with a recognisable message; a missing table, a corrupt index or a locked\n * database do not, and must not be reported as \"nothing found\".\n *\n * Matching on message text is unlovely, and the alternative — treating every\n * failure as empty — is what produced a confidently wrong answer to a human.\n */\nexport function isQuerySyntaxError(e: unknown): boolean {\n const msg = e instanceof Error ? e.message : String(e);\n return /fts5|malformed MATCH|syntax error|unterminated string|no such column/i.test(msg);\n}\n\nexport function buildFtsQuery(query: string): string {\n const tokens = query\n .toLowerCase()\n .split(/[\\s\\p{P}]+/u)\n .filter(Boolean)\n .filter((t) => t.length >= 2)\n .filter((t) => !STOP_WORDS.has(t))\n // Escape any double-quotes inside the token (FTS5 uses them as delimiters)\n .map((t) => `\"${t.replace(/\"/g, '\"\"')}\"`)\n\n if (tokens.length === 0) {\n // Fallback: use original query as a raw string (may produce no results)\n return `\"${query.replace(/\"/g, '\"\"')}\"`;\n }\n\n return tokens.join(\" OR \");\n}\n\n// The raw searchMemory()/searchMemorySemantic()/searchMemoryHybrid()/\n// touchChunksLastAccessed() implementations live in storage/sqlite/search.ts\n// (SQLite) and storage/postgres/search.ts + backend.ts (Postgres).\n\n// ---------------------------------------------------------------------------\n// Slug lookup helper\n// ---------------------------------------------------------------------------\n\n/**\n * Populate the projectSlug field on search results by looking up project IDs\n * in the registry database.\n */\nexport async function populateSlugs(\n results: SearchResult[],\n registry: RegistryBackend,\n): Promise<SearchResult[]> {\n if (results.length === 0) return results;\n\n const ids = [...new Set(results.map((r) => r.projectId))];\n // No batch \"projects by ids\" method on RegistryBackend yet (flagged in the\n // report) — loops getProjectById, bounded by the distinct project count in\n // an already-capped result set.\n const slugMap = new Map<number, string>();\n for (const id of ids) {\n const project = await registry.getProjectById(id);\n if (project) slugMap.set(id, project.slug);\n }\n\n return results.map((r) => ({\n ...r,\n projectSlug: slugMap.get(r.projectId),\n }));\n}\n\n// ---------------------------------------------------------------------------\n// Recency boost\n// ---------------------------------------------------------------------------\n\n/**\n * Apply exponential recency boost to search scores.\n *\n * Scores are first min-max normalized to [0,1], then multiplied by an\n * exponential decay factor based on chunk age. Normalization is required\n * because the cross-encoder reranker produces negative logit scores — naive\n * multiplication of a negative score by a decay factor (0 < d ≤ 1) would\n * make the score *less* negative, effectively boosting old results instead\n * of penalizing them.\n *\n * Formula: score_final = normalized * exp(-lambda * age_days)\n * where lambda = ln(2) / halfLifeDays, normalized ∈ [0,1]\n *\n * With default halfLifeDays=90, a 3-month-old chunk retains 50% of its\n * normalized score, a 6-month-old retains 25%, and a 1-year-old ~6%.\n *\n * Results without an updatedAt timestamp receive no decay penalty.\n * Results are re-sorted by the boosted score after application.\n *\n * @param results Search results with optional updatedAt timestamps.\n * @param halfLifeDays Score halves every N days. Default 90 (~3 months).\n * @returns New array sorted by decayed normalized score (descending).\n */\nexport function applyRecencyBoost(\n results: SearchResult[],\n halfLifeDays = 90,\n): SearchResult[] {\n if (halfLifeDays <= 0 || results.length === 0) return results;\n\n const lambda = Math.LN2 / halfLifeDays;\n const now = Date.now();\n\n // Min-max normalize scores to [0,1] so multiplicative decay works\n // correctly regardless of the raw score sign/scale.\n const scores = results.map((r) => r.score);\n const minScore = Math.min(...scores);\n const maxScore = Math.max(...scores);\n const range = maxScore - minScore;\n\n return results\n .map((r) => {\n const normalized = range === 0 ? 1 : (r.score - minScore) / range;\n // QW2: use the more recent of updated_at and last_accessed_at for recency decay\n const effectiveTs = r.updatedAt != null && r.lastAccessedAt != null\n ? Math.max(r.updatedAt, r.lastAccessedAt)\n : (r.lastAccessedAt ?? r.updatedAt);\n const decay = effectiveTs\n ? Math.exp(-lambda * Math.max(0, (now - effectiveTs) / 86_400_000))\n : 1; // no timestamp → no penalty\n return { ...r, score: normalized * decay };\n })\n .sort((a, b) => b.score - a.score);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4EA,SAAgB,mBAAmB,GAAqB;CACtD,MAAM,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;AACtD,QAAO,wEAAwE,KAAK,IAAI;;AAG1F,SAAgB,cAAc,OAAuB;CACnD,MAAM,SAAS,MACZ,aAAa,CACb,MAAM,cAAc,CACpB,OAAO,QAAQ,CACf,QAAQ,MAAM,EAAE,UAAU,EAAE,CAC5B,QAAQ,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,CAEjC,KAAK,MAAM,IAAI,EAAE,QAAQ,MAAM,OAAK,CAAC,GAAG;AAE3C,KAAI,OAAO,WAAW,EAEpB,QAAO,IAAI,MAAM,QAAQ,MAAM,OAAK,CAAC;AAGvC,QAAO,OAAO,KAAK,OAAO;;;;;;AAe5B,eAAsB,cACpB,SACA,UACyB;AACzB,KAAI,QAAQ,WAAW,EAAG,QAAO;CAEjC,MAAM,MAAM,CAAC,GAAG,IAAI,IAAI,QAAQ,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC;CAIzD,MAAM,0BAAU,IAAI,KAAqB;AACzC,MAAK,MAAM,MAAM,KAAK;EACpB,MAAM,UAAU,MAAM,SAAS,eAAe,GAAG;AACjD,MAAI,QAAS,SAAQ,IAAI,IAAI,QAAQ,KAAK;;AAG5C,QAAO,QAAQ,KAAK,OAAO;EACzB,GAAG;EACH,aAAa,QAAQ,IAAI,EAAE,UAAU;EACtC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;AA8BL,SAAgB,kBACd,SACA,eAAe,IACC;AAChB,KAAI,gBAAgB,KAAK,QAAQ,WAAW,EAAG,QAAO;CAEtD,MAAM,SAAS,KAAK,MAAM;CAC1B,MAAM,MAAM,KAAK,KAAK;CAItB,MAAM,SAAS,QAAQ,KAAK,MAAM,EAAE,MAAM;CAC1C,MAAM,WAAW,KAAK,IAAI,GAAG,OAAO;CAEpC,MAAM,QADW,KAAK,IAAI,GAAG,OAAO,GACX;AAEzB,QAAO,QACJ,KAAK,MAAM;EACV,MAAM,aAAa,UAAU,IAAI,KAAK,EAAE,QAAQ,YAAY;EAE5D,MAAM,cAAc,EAAE,aAAa,QAAQ,EAAE,kBAAkB,OAC3D,KAAK,IAAI,EAAE,WAAW,EAAE,eAAe,GACtC,EAAE,kBAAkB,EAAE;EAC3B,MAAM,QAAQ,cACV,KAAK,IAAI,CAAC,SAAS,KAAK,IAAI,IAAI,MAAM,eAAe,MAAW,CAAC,GACjE;AACJ,SAAO;GAAE,GAAG;GAAG,OAAO,aAAa;GAAO;GAC1C,CACD,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM"}
1
+ {"version":3,"file":"search-BOSphCJ1.mjs","names":[],"sources":["../src/memory/search.ts"],"sourcesContent":["/**\n * Query building and result post-processing for the PAI federation memory\n * index — everything here is pure logic (no SQL). The raw FTS5/vector\n * queries live under src/storage/{sqlite,postgres}/search.ts, which call\n * back into buildFtsQuery()/isQuerySyntaxError() from this module.\n */\n\nimport { STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { RegistryBackend } from \"../storage/registry-interface.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SearchResult {\n projectId: number;\n projectSlug?: string; // populated from registry after search when available\n path: string;\n startLine: number;\n endLine: number;\n snippet: string;\n score: number; // raw BM25 score (lower = more relevant in FTS5)\n tier: string;\n source: string;\n updatedAt?: number; // Unix ms from memory_chunks.updated_at\n lastAccessedAt?: number; // Unix ms from memory_chunks.last_accessed_at (QW2)\n chunkId?: string; // chunk ID for last_accessed_at update (QW2)\n}\n\nexport interface SearchOptions {\n /** Restrict search to these project IDs. */\n projectIds?: number[];\n /** Restrict to 'memory' or 'notes' sources. */\n sources?: string[];\n /** Restrict to specific tier(s): 'evergreen' | 'daily' | 'topic' | 'session' */\n tiers?: string[];\n /** Maximum number of results to return. Default 10. */\n maxResults?: number;\n /** Minimum BM25 score threshold (FTS5 scores are negative; 0.0 means no filter). */\n minScore?: number;\n}\n\n// STOP_WORDS imported from utils/stop-words.ts\n\n// ---------------------------------------------------------------------------\n// Query builder\n// ---------------------------------------------------------------------------\n\n/**\n * Convert a free-text query into an FTS5 query string.\n *\n * Strategy:\n * 1. Tokenise by whitespace and punctuation\n * 2. Remove stop words and tokens shorter than 2 characters\n * 3. Double-quote each remaining token (exact word form)\n * 4. Join with OR so that any matching token returns a result\n *\n * Using OR instead of AND is critical for multi-word queries: the words rarely\n * all appear in the same chunk, so AND would return zero results. FTS5 BM25\n * scoring naturally ranks chunks where more terms match higher, so the most\n * relevant chunks still surface at the top.\n *\n * Example: \"Synchrotech interview follow-up Gilles\"\n * → `\"synchrotech\" OR \"interview\" OR \"follow\" OR \"gilles\"`\n * → chunks matching any term, ranked by how many terms match\n */\n/**\n * Did SQLite reject the QUERY, or fail at the STORE?\n *\n * Only the first justifies an empty result. FTS5 reports a bad MATCH expression\n * with a recognisable message; a missing table, a corrupt index or a locked\n * database do not, and must not be reported as \"nothing found\".\n *\n * Matching on message text is unlovely, and the alternative — treating every\n * failure as empty — is what produced a confidently wrong answer to a human.\n */\nexport function isQuerySyntaxError(e: unknown): boolean {\n const msg = e instanceof Error ? e.message : String(e);\n return /fts5|malformed MATCH|syntax error|unterminated string|no such column/i.test(msg);\n}\n\nexport function buildFtsQuery(query: string): string {\n const tokens = query\n .toLowerCase()\n .split(/[\\s\\p{P}]+/u)\n .filter(Boolean)\n .filter((t) => t.length >= 2)\n .filter((t) => !STOP_WORDS.has(t))\n // Escape any double-quotes inside the token (FTS5 uses them as delimiters)\n .map((t) => `\"${t.replace(/\"/g, '\"\"')}\"`)\n\n if (tokens.length === 0) {\n // Fallback: use original query as a raw string (may produce no results)\n return `\"${query.replace(/\"/g, '\"\"')}\"`;\n }\n\n return tokens.join(\" OR \");\n}\n\n// The raw searchMemory()/searchMemorySemantic()/searchMemoryHybrid()/\n// touchChunksLastAccessed() implementations live in storage/sqlite/search.ts\n// (SQLite) and storage/postgres/search.ts + backend.ts (Postgres).\n\n// ---------------------------------------------------------------------------\n// Slug lookup helper\n// ---------------------------------------------------------------------------\n\n/**\n * Populate the projectSlug field on search results by looking up project IDs\n * in the registry database.\n */\nexport async function populateSlugs(\n results: SearchResult[],\n registry: RegistryBackend,\n): Promise<SearchResult[]> {\n if (results.length === 0) return results;\n\n const ids = [...new Set(results.map((r) => r.projectId))];\n // No batch \"projects by ids\" method on RegistryBackend yet (flagged in the\n // report) — loops getProjectById, bounded by the distinct project count in\n // an already-capped result set.\n const slugMap = new Map<number, string>();\n for (const id of ids) {\n const project = await registry.getProjectById(id);\n if (project) slugMap.set(id, project.slug);\n }\n\n return results.map((r) => ({\n ...r,\n projectSlug: slugMap.get(r.projectId),\n }));\n}\n\n// ---------------------------------------------------------------------------\n// Recency boost\n// ---------------------------------------------------------------------------\n\n/**\n * Apply exponential recency boost to search scores.\n *\n * Scores are first min-max normalized to [0,1], then multiplied by an\n * exponential decay factor based on chunk age. Normalization is required\n * because the cross-encoder reranker produces negative logit scores — naive\n * multiplication of a negative score by a decay factor (0 < d ≤ 1) would\n * make the score *less* negative, effectively boosting old results instead\n * of penalizing them.\n *\n * Formula: score_final = normalized * exp(-lambda * age_days)\n * where lambda = ln(2) / halfLifeDays, normalized ∈ [0,1]\n *\n * With default halfLifeDays=90, a 3-month-old chunk retains 50% of its\n * normalized score, a 6-month-old retains 25%, and a 1-year-old ~6%.\n *\n * Results without an updatedAt timestamp receive no decay penalty.\n * Results are re-sorted by the boosted score after application.\n *\n * @param results Search results with optional updatedAt timestamps.\n * @param halfLifeDays Score halves every N days. Default 90 (~3 months).\n * @returns New array sorted by decayed normalized score (descending).\n */\nexport function applyRecencyBoost(\n results: SearchResult[],\n halfLifeDays = 90,\n): SearchResult[] {\n if (halfLifeDays <= 0 || results.length === 0) return results;\n\n const lambda = Math.LN2 / halfLifeDays;\n const now = Date.now();\n\n // Min-max normalize scores to [0,1] so multiplicative decay works\n // correctly regardless of the raw score sign/scale.\n const scores = results.map((r) => r.score);\n const minScore = Math.min(...scores);\n const maxScore = Math.max(...scores);\n const range = maxScore - minScore;\n\n return results\n .map((r) => {\n const normalized = range === 0 ? 1 : (r.score - minScore) / range;\n // QW2: use the more recent of updated_at and last_accessed_at for recency decay\n const effectiveTs = r.updatedAt != null && r.lastAccessedAt != null\n ? Math.max(r.updatedAt, r.lastAccessedAt)\n : (r.lastAccessedAt ?? r.updatedAt);\n const decay = effectiveTs\n ? Math.exp(-lambda * Math.max(0, (now - effectiveTs) / 86_400_000))\n : 1; // no timestamp → no penalty\n return { ...r, score: normalized * decay };\n })\n .sort((a, b) => b.score - a.score);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4EA,SAAgB,mBAAmB,GAAqB;CACtD,MAAM,MAAM,aAAa,QAAQ,EAAE,UAAU,OAAO,EAAE;AACtD,QAAO,wEAAwE,KAAK,IAAI;;AAG1F,SAAgB,cAAc,OAAuB;CACnD,MAAM,SAAS,MACZ,aAAa,CACb,MAAM,cAAc,CACpB,OAAO,QAAQ,CACf,QAAQ,MAAM,EAAE,UAAU,EAAE,CAC5B,QAAQ,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,CAEjC,KAAK,MAAM,IAAI,EAAE,QAAQ,MAAM,OAAK,CAAC,GAAG;AAE3C,KAAI,OAAO,WAAW,EAEpB,QAAO,IAAI,MAAM,QAAQ,MAAM,OAAK,CAAC;AAGvC,QAAO,OAAO,KAAK,OAAO;;;;;;AAe5B,eAAsB,cACpB,SACA,UACyB;AACzB,KAAI,QAAQ,WAAW,EAAG,QAAO;CAEjC,MAAM,MAAM,CAAC,GAAG,IAAI,IAAI,QAAQ,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC;CAIzD,MAAM,0BAAU,IAAI,KAAqB;AACzC,MAAK,MAAM,MAAM,KAAK;EACpB,MAAM,UAAU,MAAM,SAAS,eAAe,GAAG;AACjD,MAAI,QAAS,SAAQ,IAAI,IAAI,QAAQ,KAAK;;AAG5C,QAAO,QAAQ,KAAK,OAAO;EACzB,GAAG;EACH,aAAa,QAAQ,IAAI,EAAE,UAAU;EACtC,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;AA8BL,SAAgB,kBACd,SACA,eAAe,IACC;AAChB,KAAI,gBAAgB,KAAK,QAAQ,WAAW,EAAG,QAAO;CAEtD,MAAM,SAAS,KAAK,MAAM;CAC1B,MAAM,MAAM,KAAK,KAAK;CAItB,MAAM,SAAS,QAAQ,KAAK,MAAM,EAAE,MAAM;CAC1C,MAAM,WAAW,KAAK,IAAI,GAAG,OAAO;CAEpC,MAAM,QADW,KAAK,IAAI,GAAG,OAAO,GACX;AAEzB,QAAO,QACJ,KAAK,MAAM;EACV,MAAM,aAAa,UAAU,IAAI,KAAK,EAAE,QAAQ,YAAY;EAE5D,MAAM,cAAc,EAAE,aAAa,QAAQ,EAAE,kBAAkB,OAC3D,KAAK,IAAI,EAAE,WAAW,EAAE,eAAe,GACtC,EAAE,kBAAkB,EAAE;EAC3B,MAAM,QAAQ,cACV,KAAK,IAAI,CAAC,SAAS,KAAK,IAAI,IAAI,MAAM,eAAe,MAAW,CAAC,GACjE;AACJ,SAAO;GAAE,GAAG;GAAG,OAAO,aAAa;GAAO;GAC1C,CACD,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM"}
@@ -0,0 +1,3 @@
1
+ import { i as populateSlugs, n as buildFtsQuery, r as isQuerySyntaxError, t as applyRecencyBoost } from "./search-BOSphCJ1.mjs";
2
+
3
+ export { applyRecencyBoost };
@@ -1,5 +1,5 @@
1
- import { a as paiHomePath, o as resolvePaiFile } from "./pai-home-CeOIYltV.mjs";
2
- import { D as resolveCapability, g as classModelCapability, m as WorkersConfigError, o as DEFAULT_LOG_DIR, p as WORKER_CLASSES, v as expandHome, w as providerCostTier, y as getProviderOrNative } from "./run-env-BE5xyCwy.mjs";
1
+ import { a as paiHomePath, o as resolvePaiFile } from "./pai-home-UncxWxlX.mjs";
2
+ import { D as resolveCapability, g as classModelCapability, m as WorkersConfigError, o as DEFAULT_LOG_DIR, p as WORKER_CLASSES, v as expandHome, w as providerCostTier, y as getProviderOrNative } from "./run-env-Br4Bc7Vz.mjs";
3
3
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
@@ -401,4 +401,4 @@ function stopProxy(logDir) {
401
401
 
402
402
  //#endregion
403
403
  export { statusPath as _, clearCooldown as a, probeQuota as c, resolveTarget as d, setCooldown as f, panesDir as g, ledgerPath as h, capabilityForRun as i, quotaSkipThreshold as l, eventsPath as m, ensureProxyRunning as n, isQuotaFailure as o, ensureNoMcpConfig as p, stopProxy as r, nextAutoProvider as s, DEFAULT_PROXY_PORT as t, resolveCapabilityRun as u, workersLogDir as v };
404
- //# sourceMappingURL=server-BY6AedmC.mjs.map
404
+ //# sourceMappingURL=server-B71rem4q.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"server-BY6AedmC.mjs","names":[],"sources":["../src/workers/paths.ts","../src/workers/routing.ts","../src/workers/proxy/server.ts"],"sourcesContent":["/**\n * paths.ts — where worker run artefacts live.\n *\n * Everything a run writes (event mirror, status file, pane registry, routing\n * state, the empty MCP config) sits under one logDir so the whole tree is\n * disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { expandHome, DEFAULT_LOG_DIR, type WorkersConfig } from \"./config.js\";\nimport { paiHomePath, resolvePaiFile } from \"../config/pai-home.js\";\n\n/**\n * Absolute logDir for the given config. An explicit `logDir` in workers.yaml\n * is honored verbatim (the user chose it). Otherwise this resolves like every\n * other PAI_HOME file: PAI_HOME/logs/workers if it exists, else the old\n * ~/.claude/logs/workers (with a one-time notice), else the new path.\n */\nexport function workersLogDir(config: WorkersConfig): string {\n const expanded = expandHome(config.logDir);\n if (config.logDir !== DEFAULT_LOG_DIR) return expanded;\n return resolvePaiFile(paiHomePath(\"logs\", \"workers\"), [expanded], \"pai config migrate --logs\");\n}\n\n/** The strict empty MCP config for headless runs, written on demand. */\nexport function ensureNoMcpConfig(logDir: string): string {\n const path = noMcpConfigPath(logDir);\n if (!existsSync(path)) {\n mkdirSync(logDir, { recursive: true });\n writeFileSync(path, '{ \"mcpServers\": {} }\\n', \"utf8\");\n }\n return path;\n}\n\nexport function statusPath(logDir: string, id: string): string {\n return join(logDir, `${id}.status`);\n}\n\nexport function eventsPath(logDir: string, id: string): string {\n return join(logDir, `${id}.jsonl`);\n}\n\nexport function ledgerPath(logDir: string): string {\n return join(logDir, \"ledger.log\");\n}\n\nexport function routingStatePath(logDir: string): string {\n return join(logDir, \"routing-state.json\");\n}\n\nexport function panesDir(logDir: string): string {\n return join(logDir, \"panes\");\n}\n\n/**\n * The strict empty MCP config handed to headless workers. Written into the\n * logDir on demand (never into the user's vendor config directory — this is\n * PAI state, not vendor state).\n */\nexport function noMcpConfigPath(logDir: string): string {\n return join(logDir, \"no-mcp.json\");\n}\n","/**\n * routing.ts — provider selection: flag > class > active (possibly \"auto\").\n *\n * Auto-routing walks `workers.routing.order` — or the class's own `order` —\n * and takes the first provider that is enabled, out of cooldown, (when it\n * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class\n * constrains it — within `maxCostTier` and carrying all `requireTags`. A run\n * that dies of a quota/rate error puts its provider in cooldown for\n * cooldownMinutes; when that happens before the first tool call and\n * retryOnQuota is set, the runner restarts the same task on the next provider\n * (ledger: WORKER-REROUTE).\n *\n * An explicit --provider or a class mapping that pins a provider always\n * bypasses all of this.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n type WorkerEngine,\n type WorkerProvider,\n type WorkersConfig,\n type ProviderTag,\n WORKER_CLASSES,\n WorkersConfigError,\n classModelCapability,\n providerCostTier,\n getProviderOrNative,\n resolveCapability,\n} from \"./config.js\";\nimport { routingStatePath } from \"./paths.js\";\n\nconst QUOTA_SKIP_DEFAULT = 95;\nconst PROBE_TIMEOUT_MS = 10_000;\n\nexport interface RoutingState {\n /** provider name → ISO timestamp when its cooldown ends */\n cooldowns: Record<string, string>;\n}\n\nexport function readRoutingState(logDir: string): RoutingState {\n const path = routingStatePath(logDir);\n if (!existsSync(path)) return { cooldowns: {} };\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as RoutingState;\n return { cooldowns: parsed.cooldowns ?? {} };\n } catch {\n return { cooldowns: {} };\n }\n}\n\nexport function writeRoutingState(logDir: string, state: RoutingState): void {\n const path = routingStatePath(logDir);\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n writeFileSync(tmp, JSON.stringify(state, null, 2) + \"\\n\", \"utf8\");\n renameSync(tmp, path);\n}\n\nexport function cooldownRemaining(\n state: RoutingState,\n provider: string,\n now: Date = new Date()\n): number {\n const end = state.cooldowns[provider];\n if (!end) return 0;\n const ms = Date.parse(end) - now.getTime();\n return ms > 0 ? ms : 0;\n}\n\nexport function setCooldown(\n logDir: string,\n provider: string,\n minutes: number,\n now: Date = new Date()\n): void {\n const state = readRoutingState(logDir);\n state.cooldowns[provider] = new Date(now.getTime() + minutes * 60_000).toISOString();\n writeRoutingState(logDir, state);\n}\n\nexport function clearCooldown(logDir: string, provider: string): void {\n const state = readRoutingState(logDir);\n if (!(provider in state.cooldowns)) return;\n delete state.cooldowns[provider];\n writeRoutingState(logDir, state);\n}\n\n/**\n * Run a provider's quotaProbe and return its percentage (0–100), or null when\n * there is no probe or it printed nothing usable. A probe must never break\n * routing: failures read as \"unknown\", not as \"full\".\n */\nexport function probeQuota(provider: WorkerProvider): number | null {\n if (!provider.quotaProbe) return null;\n try {\n const out = execFileSync(\"/bin/sh\", [\"-c\", provider.quotaProbe], {\n timeout: PROBE_TIMEOUT_MS,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const m = out.match(/(\\d+(?:\\.\\d+)?)/);\n if (!m) return null;\n return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));\n } catch {\n return null;\n }\n}\n\nexport function quotaSkipThreshold(provider: WorkerProvider): number {\n return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;\n}\n\n/** Does this provider exceed its quota threshold right now? */\nexport function quotaExceeded(provider: WorkerProvider): boolean {\n const used = probeQuota(provider);\n return used !== null && used >= quotaSkipThreshold(provider);\n}\n\nexport interface ResolvedTarget {\n providerName: string;\n provider: WorkerProvider;\n /** Model alias from the class (\"glm/fast\" → \"fast\"), null = provider default. */\n modelAlias: string | null;\n /** MCP servers (or set names) from the class target, null when it sets none. */\n classMcp: string[] | null;\n /** How the provider was chosen — names the bypass rule for rerouting. */\n via: \"flag\" | \"class\" | \"active\" | \"auto\";\n}\n\nexport class NoProviderError extends WorkersConfigError {}\n\nfunction mustExist(config: WorkersConfig, name: string): WorkerProvider {\n const p = getProviderOrNative(config, name);\n if (!p) {\n throw new NoProviderError(\n `no worker provider named \"${name}\". Configured: ` +\n `${Object.keys(config.providers).join(\", \") || \"(none)\"}.` +\n `\\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`\n );\n }\n return p;\n}\n\nfunction mustBeRunnable(name: string, p: WorkerProvider): WorkerProvider {\n if (!p.enabled) {\n throw new NoProviderError(\n `provider \"${name}\" is disabled. Enable it with: pai worker providers enable ${name}`\n );\n }\n return p;\n}\n\n/** Why one provider of a routing order did not qualify (message fragment). */\nfunction exclusionReason(\n config: WorkersConfig,\n state: RoutingState,\n name: string,\n cls?: { maxCostTier?: number; requireTags?: string[] }\n): string | null {\n const p = getProviderOrNative(config, name);\n if (!p) return \"not configured\";\n if (!p.enabled) return \"disabled\";\n if (cooldownRemaining(state, name) > 0) return \"cooldown\";\n if (quotaExceeded(p)) return \"quota\";\n const tier = providerCostTier(p);\n if (cls?.maxCostTier !== undefined && tier > cls.maxCostTier) {\n return `cost tier ${tier} > max ${cls.maxCostTier}`;\n }\n if (cls?.requireTags?.length) {\n const have = p.tags ?? [];\n const missing = cls.requireTags.filter((t) => !have.includes(t as ProviderTag));\n if (missing.length) return `missing tags: ${missing.join(\", \")}`;\n }\n return null;\n}\n\n/**\n * Resolve which provider (and model alias) a run uses.\n *\n * @param flagProvider --provider value, highest precedence\n * @param className --class value (the old --role), looked up in workers.classes\n */\nexport function resolveTarget(\n config: WorkersConfig,\n logDir: string,\n opts: { flagProvider?: string; className?: string } = {}\n): ResolvedTarget {\n if (opts.flagProvider) {\n return {\n providerName: opts.flagProvider,\n provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),\n modelAlias: null,\n classMcp: null,\n via: \"flag\",\n };\n }\n\n // class constraints (present whether or not the target pins a provider)\n const clsTarget = opts.className ? config.classes[opts.className] : undefined;\n const cls =\n typeof clsTarget === \"object\" && clsTarget !== null ? clsTarget : undefined;\n\n if (opts.className && clsTarget !== undefined && typeof clsTarget !== \"object\") {\n const [name, alias] = clsTarget.split(\"/\");\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: alias ?? null,\n classMcp: null,\n via: \"class\",\n };\n }\n if (opts.className && cls?.provider) {\n const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));\n return {\n providerName: cls.provider,\n provider,\n modelAlias: null,\n classMcp: cls.mcp ?? null,\n via: \"class\",\n };\n }\n if (opts.className && clsTarget === undefined) {\n // a standard class may simply be unconfigured (it then routes like a run\n // without a class); anything else is a typo and must not pass silently\n if (!(WORKER_CLASSES as readonly string[]).includes(opts.className)) {\n throw new NoProviderError(\n `no class named \"${opts.className}\". Standard classes: ${WORKER_CLASSES.join(\", \")}.` +\n `Defined: ${Object.keys(config.classes).join(\", \") || \"(none)\"}.` +\n `\\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`\n );\n }\n }\n\n if (config.active !== \"auto\") {\n const name = config.active;\n if (!name) {\n throw new NoProviderError(\n `no active worker provider. Add one with: ` +\n `pai worker providers add <name> --base-url <url> --key-file <path> --model <model>` +\n `\\n(or point one that exists at it: pai worker providers use <name>)`\n );\n }\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: null,\n classMcp: null,\n via: \"active\",\n };\n }\n\n // auto: first provider in (class or global) order that is enabled,\n // cooled-down-free, under quota and within the class constraints\n const state = readRoutingState(logDir);\n const order = cls?.order ?? config.routing.order;\n const excluded: string[] = [];\n for (const name of order) {\n const p = getProviderOrNative(config, name);\n const why = exclusionReason(config, state, name, cls);\n if (why) {\n excluded.push(`${name}: ${why}`);\n continue;\n }\n // exclusionReason already returned null (no exclusion) above, which is\n // only possible when getProviderOrNative found a real provider\n return { providerName: name, provider: p!, modelAlias: null, classMcp: cls?.mcp ?? null, via: \"auto\" };\n }\n throw new NoProviderError(\n `auto-routing found no usable provider${opts.className ? ` for class \"${opts.className}\"` : \"\"} ` +\n `(order: [${order.join(\", \")}]).` +\n `${excluded.length ? `\\nExcluded: ${excluded.join(\"; \")}.` : \"\"}` +\n `\\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? \"<class>\"}=<target>`\n );\n}\n\n/**\n * Next provider after `from` in auto order, applying the same filters.\n * Used by rerouting; null when the order is exhausted.\n */\nexport function nextAutoProvider(\n config: WorkersConfig,\n logDir: string,\n from: string,\n now: Date = new Date()\n): string | null {\n const state = readRoutingState(logDir);\n const order = config.routing.order;\n const start = order.indexOf(from);\n for (let i = start + 1; i < order.length; i++) {\n const name = order[i];\n const p = getProviderOrNative(config, name);\n if (!p || !p.enabled) continue;\n const end = state.cooldowns[name];\n if (end && Date.parse(end) > now.getTime()) continue;\n if (quotaExceeded(p)) continue;\n return name;\n }\n return null;\n}\n\n/**\n * Was this failure a quota/rate failure? Detected from the result text the\n * endpoint produced (HTTP status is not visible in the stream events).\n */\nexport function isQuotaFailure(resultText: string): boolean {\n return /usage limit reached|rate limit|quota/i.test(resultText);\n}\n\nexport interface CapabilityRunResolution {\n providerName: string;\n provider: WorkerProvider;\n model: string;\n engine: WorkerEngine;\n fellBack: boolean;\n capability: string;\n}\n\n/**\n * Whether a run should resolve through `resolveCapability` instead of\n * `resolveTarget`: an explicit `--capability` always does. A `--class`\n * whose implied capability is not \"default\" (e.g. `image`) does too, but\n * only when the class itself names no provider — a class already pinned to\n * one (`image: glm/image`) always wins unchanged, same as any other class.\n */\nexport function capabilityForRun(\n config: WorkersConfig,\n opts: { capabilityFlag?: string; className?: string }\n): string | null {\n if (opts.capabilityFlag) return opts.capabilityFlag;\n if (!opts.className) return null;\n const capability = classModelCapability(opts.className);\n if (capability === \"default\") return null;\n const clsTarget = config.classes[opts.className];\n const namesProvider = typeof clsTarget === \"string\" ? true : Boolean(clsTarget?.provider);\n return namesProvider ? null : capability;\n}\n\n/**\n * resolveCapability, turned into a runnable provider (NoProviderError-shaped\n * on failure). Unlike resolveCapability itself — which falls back to the\n * active provider's default model so `describeCapabilities` has something to\n * report — an actual run refuses the fallback: silently running a normal\n * model on a prompt written for a capability it does not have (an image\n * prompt fed to a text model, say) produces a confusing non-answer instead\n * of a clear error naming the config key to set.\n */\nexport function resolveCapabilityRun(config: WorkersConfig, capability: string): CapabilityRunResolution {\n const r = resolveCapability(config, capability);\n if (r.fellBack) {\n throw new NoProviderError(\n `no provider declares the \"${capability}\" capability — configure one with: ` +\n `pai worker capability ${capability} <provider>[,<provider>…], or ` +\n `pai worker model ${capability} <model-id> --provider <name>`\n );\n }\n // \"image\" specifically promises real pixel output — a provider whose only\n // claim to it is a `models.image` entry from the classes-based idiom\n // (`image: glm/image`, a plain model choice on an unchanged claude/codex\n // engine) cannot deliver that through this cross-provider run path, so a\n // run refuses it rather than spawning claude on a prompt it cannot answer.\n if (capability === \"image\" && r.engine !== \"image\") {\n throw new NoProviderError(\n `provider \"${r.provider}\" has no image engine (it would run \"${r.model}\" on its normal ` +\n `${r.engine} engine, not generate an image) — configure an engine=image provider with: ` +\n `pai worker providers add <name> --engine image --url <url> --key <key> --model <id>, then ` +\n `pai worker capability image <name>`\n );\n }\n const provider = getProviderOrNative(config, r.provider);\n if (!provider) {\n throw new NoProviderError(`capability \"${capability}\" resolved to unknown provider \"${r.provider}\"`);\n }\n return {\n providerName: r.provider,\n provider,\n model: r.model,\n engine: r.engine,\n fellBack: r.fellBack,\n capability,\n };\n}\n","/**\n * server.ts — the PAI worker proxy: Anthropic Messages API on the front,\n * OpenAI Chat Completions on the back, loopback only.\n *\n * One proxy serves every OpenAI-protocol provider: Claude Code points\n * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider\n * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the\n * workers config, re-read per request so config edits apply without a\n * restart). `run` starts the proxy on demand (detached, pid file under the\n * logDir) and `pai worker proxy stop` stops it again.\n *\n * All translation lives in translate.ts; this file is only HTTP plumbing.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createServer, type Server } from \"node:http\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from \"node:fs\";\nimport { connect } from \"node:net\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { readWorkersSection, resolveProviderKey, type WorkerProvider } from \"../config.js\";\nimport { workersLogDir } from \"../paths.js\";\nimport {\n anthropicError,\n anthropicToOpenAi,\n openAiToAnthropic,\n OpenAiStreamTranslator,\n type AnthropicRequest,\n} from \"./translate.js\";\n\nexport const DEFAULT_PROXY_PORT = 8797;\nconst HOST = \"127.0.0.1\";\n\n/** How providers are looked up — swapped out by the tests. */\nexport type ProviderResolver = () => Record<string, WorkerProvider>;\n\nconst configProviders: ProviderResolver = () => readWorkersSection().workers.providers;\n\nexport function proxyPidPath(logDir: string): string {\n return join(logDir, \"proxy.pid\");\n}\n\n// ---------------------------------------------------------------------------\n// HTTP server\n// ---------------------------------------------------------------------------\n\ninterface ProxyServerOptions {\n port?: number;\n resolveProvider?: ProviderResolver;\n /** Test hook: called with every (translated) upstream request body. */\n onUpstreamRequest?: (url: string, body: unknown) => void;\n}\n\n/**\n * Create (but not start) the proxy server. Routes:\n * GET /healthz → 200 ok\n * POST /<provider>/v1/messages (and /<provider>/messages) → translated call\n */\nexport function createProxyServer(opts: ProxyServerOptions = {}): Server {\n const resolveProvider = opts.resolveProvider ?? configProviders;\n return createServer((req, res) => {\n const url = new URL(req.url ?? \"/\", `http://${HOST}`);\n if (req.method === \"GET\" && url.pathname === \"/healthz\") {\n res.writeHead(200, { \"content-type\": \"text/plain\" });\n res.end(\"ok\");\n return;\n }\n const m = url.pathname.match(/^\\/([a-zA-Z0-9_-]+)\\/(v1\\/)?messages$/);\n if (req.method !== \"POST\" || !m) {\n res.writeHead(404, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(404, `no such route: ${req.method} ${url.pathname}`)));\n return;\n }\n const providerName = m[1];\n const chunks: Buffer[] = [];\n req.on(\"data\", (c: Buffer) => chunks.push(c));\n req.on(\"end\", () => {\n void handleMessages(providerName, Buffer.concat(chunks).toString(\"utf8\"), resolveProvider, res, opts);\n });\n req.on(\"error\", () => {\n res.writeHead(400, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(400, \"request body read failed\")));\n });\n });\n}\n\nasync function handleMessages(\n providerName: string,\n bodyText: string,\n resolveProvider: ProviderResolver,\n res: import(\"node:http\").ServerResponse,\n opts: ProxyServerOptions\n): Promise<void> {\n const fail = (status: number, message: string) => {\n res.writeHead(status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(status, JSON.stringify({ message }))));\n };\n let reqBody: AnthropicRequest;\n try {\n reqBody = JSON.parse(bodyText) as AnthropicRequest;\n } catch {\n fail(400, \"request body is not valid JSON\");\n return;\n }\n const provider = resolveProvider()[providerName];\n if (!provider) {\n fail(404, `no worker provider named \"${providerName}\"`);\n return;\n }\n if (provider.protocol !== \"openai\" || !provider.upstreamUrl) {\n fail(400, `provider \"${providerName}\" is not an openai-protocol provider`);\n return;\n }\n\n const model = provider.models.default;\n const openaiBody = anthropicToOpenAi(reqBody, model);\n opts.onUpstreamRequest?.(`${provider.upstreamUrl}/chat/completions`, openaiBody);\n\n let upstream: Response;\n try {\n const headers: Record<string, string> = { \"content-type\": \"application/json\" };\n let token: string | null;\n try {\n token = resolveProviderKey(provider);\n } catch (e) {\n fail(500, e instanceof Error ? e.message : String(e));\n return;\n }\n if (token) headers.authorization = `Bearer ${token}`;\n upstream = await fetch(`${provider.upstreamUrl}/chat/completions`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(openaiBody),\n });\n } catch (e) {\n fail(502, `upstream unreachable: ${e instanceof Error ? e.message : String(e)}`);\n return;\n }\n\n if (!upstream.ok) {\n const text = await upstream.text().catch(() => \"\");\n res.writeHead(upstream.status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(upstream.status, text)));\n return;\n }\n\n if (reqBody.stream) {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n const translator = new OpenAiStreamTranslator(model);\n let buf = \"\";\n if (!upstream.body) {\n res.end(translator.finish());\n return;\n }\n const reader = upstream.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += new TextDecoder().decode(value, { stream: true });\n let nl: number;\n while ((nl = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, nl).trim();\n buf = buf.slice(nl + 1);\n if (!line.startsWith(\"data:\")) continue;\n const payload = line.slice(5).trim();\n if (payload === \"[DONE]\") {\n res.write(translator.finish());\n } else {\n try {\n res.write(translator.feed(JSON.parse(payload) as Parameters<typeof translator.feed>[0]));\n } catch {\n // skip a malformed chunk rather than kill the stream\n }\n }\n }\n }\n } finally {\n res.write(translator.finish()); // no-op when [DONE] already finished it\n res.end();\n }\n return;\n }\n\n const json = (await upstream.json().catch(() => null)) as unknown;\n if (!json) {\n fail(502, \"upstream returned a non-JSON body\");\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(openAiToAnthropic(json as Parameters<typeof openAiToAnthropic>[0], model)));\n}\n\n// ---------------------------------------------------------------------------\n// Start / stop\n// ---------------------------------------------------------------------------\n\n/** Listen on host:port (loopback). Resolves with the bound port. */\nexport function listenProxy(server: Server, port = DEFAULT_PROXY_PORT): Promise<number> {\n return new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(port, HOST, () => {\n const addr = server.address();\n resolve(typeof addr === \"object\" && addr ? addr.port : port);\n });\n });\n}\n\n/** True when something already accepts connections on the port. */\nexport function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const sock = connect({ port, host: HOST });\n const done = (ok: boolean) => {\n sock.removeAllListeners();\n sock.destroy();\n resolve(ok);\n };\n sock.setTimeout(timeoutMs, () => done(false));\n sock.once(\"connect\", () => done(true));\n sock.once(\"error\", () => done(false));\n });\n}\n\n/**\n * Where the standalone proxy artefact lives, found from this module's own\n * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).\n */\nexport function standaloneProxyPath(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 6; i++) {\n for (const cand of [\n join(dir, \"worker-proxy.mjs\"),\n join(dir, \"hooks\", \"worker-proxy.mjs\"),\n join(dir, \"dist\", \"hooks\", \"worker-proxy.mjs\"),\n ]) {\n if (existsSync(cand)) return cand;\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/**\n * Ensure a proxy is listening on the port; start the detached standalone when\n * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).\n */\nexport async function ensureProxyRunning(port: number, logDir: string): Promise<string> {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n const script = standaloneProxyPath();\n if (!script) {\n throw new Error(\n `the PAI worker proxy is not running and its build was not found — ` +\n `run \\`bun run build\\` (or start one with: pai worker proxy --port ${port})`\n );\n }\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n const child = spawn(process.execPath, [script, \"--port\", String(port)], {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n writeFileSync(proxyPidPath(logDir), `${child.pid}\\n`, \"utf8\");\n for (let i = 0; i < 40; i++) {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n await new Promise((r) => setTimeout(r, 250));\n }\n throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);\n}\n\n/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */\nexport function stopProxy(logDir: string): string {\n const path = proxyPidPath(logDir);\n if (!existsSync(path)) return \"no proxy pid file — nothing to stop\";\n const pid = parseInt(readFileSync(path, \"utf8\").trim(), 10);\n unlinkSync(path);\n if (!Number.isFinite(pid)) return \"stale proxy pid file removed\";\n try {\n process.kill(pid, \"SIGTERM\");\n } catch {\n return `proxy pid ${pid} was not running (pid file removed)`;\n }\n return `proxy pid ${pid} stopped`;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"server-B71rem4q.mjs","names":[],"sources":["../src/workers/paths.ts","../src/workers/routing.ts","../src/workers/proxy/server.ts"],"sourcesContent":["/**\n * paths.ts — where worker run artefacts live.\n *\n * Everything a run writes (event mirror, status file, pane registry, routing\n * state, the empty MCP config) sits under one logDir so the whole tree is\n * disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.\n */\n\nimport { existsSync, mkdirSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { expandHome, DEFAULT_LOG_DIR, type WorkersConfig } from \"./config.js\";\nimport { paiHomePath, resolvePaiFile } from \"../config/pai-home.js\";\n\n/**\n * Absolute logDir for the given config. An explicit `logDir` in workers.yaml\n * is honored verbatim (the user chose it). Otherwise this resolves like every\n * other PAI_HOME file: PAI_HOME/logs/workers if it exists, else the old\n * ~/.claude/logs/workers (with a one-time notice), else the new path.\n */\nexport function workersLogDir(config: WorkersConfig): string {\n const expanded = expandHome(config.logDir);\n if (config.logDir !== DEFAULT_LOG_DIR) return expanded;\n return resolvePaiFile(paiHomePath(\"logs\", \"workers\"), [expanded], \"pai config migrate --logs\");\n}\n\n/** The strict empty MCP config for headless runs, written on demand. */\nexport function ensureNoMcpConfig(logDir: string): string {\n const path = noMcpConfigPath(logDir);\n if (!existsSync(path)) {\n mkdirSync(logDir, { recursive: true });\n writeFileSync(path, '{ \"mcpServers\": {} }\\n', \"utf8\");\n }\n return path;\n}\n\nexport function statusPath(logDir: string, id: string): string {\n return join(logDir, `${id}.status`);\n}\n\nexport function eventsPath(logDir: string, id: string): string {\n return join(logDir, `${id}.jsonl`);\n}\n\nexport function ledgerPath(logDir: string): string {\n return join(logDir, \"ledger.log\");\n}\n\nexport function routingStatePath(logDir: string): string {\n return join(logDir, \"routing-state.json\");\n}\n\nexport function panesDir(logDir: string): string {\n return join(logDir, \"panes\");\n}\n\n/**\n * The strict empty MCP config handed to headless workers. Written into the\n * logDir on demand (never into the user's vendor config directory — this is\n * PAI state, not vendor state).\n */\nexport function noMcpConfigPath(logDir: string): string {\n return join(logDir, \"no-mcp.json\");\n}\n","/**\n * routing.ts — provider selection: flag > class > active (possibly \"auto\").\n *\n * Auto-routing walks `workers.routing.order` — or the class's own `order` —\n * and takes the first provider that is enabled, out of cooldown, (when it\n * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class\n * constrains it — within `maxCostTier` and carrying all `requireTags`. A run\n * that dies of a quota/rate error puts its provider in cooldown for\n * cooldownMinutes; when that happens before the first tool call and\n * retryOnQuota is set, the runner restarts the same task on the next provider\n * (ledger: WORKER-REROUTE).\n *\n * An explicit --provider or a class mapping that pins a provider always\n * bypasses all of this.\n */\n\nimport { execFileSync } from \"node:child_process\";\nimport { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport {\n type WorkerEngine,\n type WorkerProvider,\n type WorkersConfig,\n type ProviderTag,\n WORKER_CLASSES,\n WorkersConfigError,\n classModelCapability,\n providerCostTier,\n getProviderOrNative,\n resolveCapability,\n} from \"./config.js\";\nimport { routingStatePath } from \"./paths.js\";\n\nconst QUOTA_SKIP_DEFAULT = 95;\nconst PROBE_TIMEOUT_MS = 10_000;\n\nexport interface RoutingState {\n /** provider name → ISO timestamp when its cooldown ends */\n cooldowns: Record<string, string>;\n}\n\nexport function readRoutingState(logDir: string): RoutingState {\n const path = routingStatePath(logDir);\n if (!existsSync(path)) return { cooldowns: {} };\n try {\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as RoutingState;\n return { cooldowns: parsed.cooldowns ?? {} };\n } catch {\n return { cooldowns: {} };\n }\n}\n\nexport function writeRoutingState(logDir: string, state: RoutingState): void {\n const path = routingStatePath(logDir);\n const dir = dirname(path);\n if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n const tmp = `${path}.tmp`;\n writeFileSync(tmp, JSON.stringify(state, null, 2) + \"\\n\", \"utf8\");\n renameSync(tmp, path);\n}\n\nexport function cooldownRemaining(\n state: RoutingState,\n provider: string,\n now: Date = new Date()\n): number {\n const end = state.cooldowns[provider];\n if (!end) return 0;\n const ms = Date.parse(end) - now.getTime();\n return ms > 0 ? ms : 0;\n}\n\nexport function setCooldown(\n logDir: string,\n provider: string,\n minutes: number,\n now: Date = new Date()\n): void {\n const state = readRoutingState(logDir);\n state.cooldowns[provider] = new Date(now.getTime() + minutes * 60_000).toISOString();\n writeRoutingState(logDir, state);\n}\n\nexport function clearCooldown(logDir: string, provider: string): void {\n const state = readRoutingState(logDir);\n if (!(provider in state.cooldowns)) return;\n delete state.cooldowns[provider];\n writeRoutingState(logDir, state);\n}\n\n/**\n * Run a provider's quotaProbe and return its percentage (0–100), or null when\n * there is no probe or it printed nothing usable. A probe must never break\n * routing: failures read as \"unknown\", not as \"full\".\n */\nexport function probeQuota(provider: WorkerProvider): number | null {\n if (!provider.quotaProbe) return null;\n try {\n const out = execFileSync(\"/bin/sh\", [\"-c\", provider.quotaProbe], {\n timeout: PROBE_TIMEOUT_MS,\n encoding: \"utf8\",\n stdio: [\"ignore\", \"pipe\", \"ignore\"],\n });\n const m = out.match(/(\\d+(?:\\.\\d+)?)/);\n if (!m) return null;\n return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));\n } catch {\n return null;\n }\n}\n\nexport function quotaSkipThreshold(provider: WorkerProvider): number {\n return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;\n}\n\n/** Does this provider exceed its quota threshold right now? */\nexport function quotaExceeded(provider: WorkerProvider): boolean {\n const used = probeQuota(provider);\n return used !== null && used >= quotaSkipThreshold(provider);\n}\n\nexport interface ResolvedTarget {\n providerName: string;\n provider: WorkerProvider;\n /** Model alias from the class (\"glm/fast\" → \"fast\"), null = provider default. */\n modelAlias: string | null;\n /** MCP servers (or set names) from the class target, null when it sets none. */\n classMcp: string[] | null;\n /** How the provider was chosen — names the bypass rule for rerouting. */\n via: \"flag\" | \"class\" | \"active\" | \"auto\";\n}\n\nexport class NoProviderError extends WorkersConfigError {}\n\nfunction mustExist(config: WorkersConfig, name: string): WorkerProvider {\n const p = getProviderOrNative(config, name);\n if (!p) {\n throw new NoProviderError(\n `no worker provider named \"${name}\". Configured: ` +\n `${Object.keys(config.providers).join(\", \") || \"(none)\"}.` +\n `\\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`\n );\n }\n return p;\n}\n\nfunction mustBeRunnable(name: string, p: WorkerProvider): WorkerProvider {\n if (!p.enabled) {\n throw new NoProviderError(\n `provider \"${name}\" is disabled. Enable it with: pai worker providers enable ${name}`\n );\n }\n return p;\n}\n\n/** Why one provider of a routing order did not qualify (message fragment). */\nfunction exclusionReason(\n config: WorkersConfig,\n state: RoutingState,\n name: string,\n cls?: { maxCostTier?: number; requireTags?: string[] }\n): string | null {\n const p = getProviderOrNative(config, name);\n if (!p) return \"not configured\";\n if (!p.enabled) return \"disabled\";\n if (cooldownRemaining(state, name) > 0) return \"cooldown\";\n if (quotaExceeded(p)) return \"quota\";\n const tier = providerCostTier(p);\n if (cls?.maxCostTier !== undefined && tier > cls.maxCostTier) {\n return `cost tier ${tier} > max ${cls.maxCostTier}`;\n }\n if (cls?.requireTags?.length) {\n const have = p.tags ?? [];\n const missing = cls.requireTags.filter((t) => !have.includes(t as ProviderTag));\n if (missing.length) return `missing tags: ${missing.join(\", \")}`;\n }\n return null;\n}\n\n/**\n * Resolve which provider (and model alias) a run uses.\n *\n * @param flagProvider --provider value, highest precedence\n * @param className --class value (the old --role), looked up in workers.classes\n */\nexport function resolveTarget(\n config: WorkersConfig,\n logDir: string,\n opts: { flagProvider?: string; className?: string } = {}\n): ResolvedTarget {\n if (opts.flagProvider) {\n return {\n providerName: opts.flagProvider,\n provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),\n modelAlias: null,\n classMcp: null,\n via: \"flag\",\n };\n }\n\n // class constraints (present whether or not the target pins a provider)\n const clsTarget = opts.className ? config.classes[opts.className] : undefined;\n const cls =\n typeof clsTarget === \"object\" && clsTarget !== null ? clsTarget : undefined;\n\n if (opts.className && clsTarget !== undefined && typeof clsTarget !== \"object\") {\n const [name, alias] = clsTarget.split(\"/\");\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: alias ?? null,\n classMcp: null,\n via: \"class\",\n };\n }\n if (opts.className && cls?.provider) {\n const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));\n return {\n providerName: cls.provider,\n provider,\n modelAlias: null,\n classMcp: cls.mcp ?? null,\n via: \"class\",\n };\n }\n if (opts.className && clsTarget === undefined) {\n // a standard class may simply be unconfigured (it then routes like a run\n // without a class); anything else is a typo and must not pass silently\n if (!(WORKER_CLASSES as readonly string[]).includes(opts.className)) {\n throw new NoProviderError(\n `no class named \"${opts.className}\". Standard classes: ${WORKER_CLASSES.join(\", \")}.` +\n `Defined: ${Object.keys(config.classes).join(\", \") || \"(none)\"}.` +\n `\\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`\n );\n }\n }\n\n if (config.active !== \"auto\") {\n const name = config.active;\n if (!name) {\n throw new NoProviderError(\n `no active worker provider. Add one with: ` +\n `pai worker providers add <name> --base-url <url> --key-file <path> --model <model>` +\n `\\n(or point one that exists at it: pai worker providers use <name>)`\n );\n }\n return {\n providerName: name,\n provider: mustBeRunnable(name, mustExist(config, name)),\n modelAlias: null,\n classMcp: null,\n via: \"active\",\n };\n }\n\n // auto: first provider in (class or global) order that is enabled,\n // cooled-down-free, under quota and within the class constraints\n const state = readRoutingState(logDir);\n const order = cls?.order ?? config.routing.order;\n const excluded: string[] = [];\n for (const name of order) {\n const p = getProviderOrNative(config, name);\n const why = exclusionReason(config, state, name, cls);\n if (why) {\n excluded.push(`${name}: ${why}`);\n continue;\n }\n // exclusionReason already returned null (no exclusion) above, which is\n // only possible when getProviderOrNative found a real provider\n return { providerName: name, provider: p!, modelAlias: null, classMcp: cls?.mcp ?? null, via: \"auto\" };\n }\n throw new NoProviderError(\n `auto-routing found no usable provider${opts.className ? ` for class \"${opts.className}\"` : \"\"} ` +\n `(order: [${order.join(\", \")}]).` +\n `${excluded.length ? `\\nExcluded: ${excluded.join(\"; \")}.` : \"\"}` +\n `\\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? \"<class>\"}=<target>`\n );\n}\n\n/**\n * Next provider after `from` in auto order, applying the same filters.\n * Used by rerouting; null when the order is exhausted.\n */\nexport function nextAutoProvider(\n config: WorkersConfig,\n logDir: string,\n from: string,\n now: Date = new Date()\n): string | null {\n const state = readRoutingState(logDir);\n const order = config.routing.order;\n const start = order.indexOf(from);\n for (let i = start + 1; i < order.length; i++) {\n const name = order[i];\n const p = getProviderOrNative(config, name);\n if (!p || !p.enabled) continue;\n const end = state.cooldowns[name];\n if (end && Date.parse(end) > now.getTime()) continue;\n if (quotaExceeded(p)) continue;\n return name;\n }\n return null;\n}\n\n/**\n * Was this failure a quota/rate failure? Detected from the result text the\n * endpoint produced (HTTP status is not visible in the stream events).\n */\nexport function isQuotaFailure(resultText: string): boolean {\n return /usage limit reached|rate limit|quota/i.test(resultText);\n}\n\nexport interface CapabilityRunResolution {\n providerName: string;\n provider: WorkerProvider;\n model: string;\n engine: WorkerEngine;\n fellBack: boolean;\n capability: string;\n}\n\n/**\n * Whether a run should resolve through `resolveCapability` instead of\n * `resolveTarget`: an explicit `--capability` always does. A `--class`\n * whose implied capability is not \"default\" (e.g. `image`) does too, but\n * only when the class itself names no provider — a class already pinned to\n * one (`image: glm/image`) always wins unchanged, same as any other class.\n */\nexport function capabilityForRun(\n config: WorkersConfig,\n opts: { capabilityFlag?: string; className?: string }\n): string | null {\n if (opts.capabilityFlag) return opts.capabilityFlag;\n if (!opts.className) return null;\n const capability = classModelCapability(opts.className);\n if (capability === \"default\") return null;\n const clsTarget = config.classes[opts.className];\n const namesProvider = typeof clsTarget === \"string\" ? true : Boolean(clsTarget?.provider);\n return namesProvider ? null : capability;\n}\n\n/**\n * resolveCapability, turned into a runnable provider (NoProviderError-shaped\n * on failure). Unlike resolveCapability itself — which falls back to the\n * active provider's default model so `describeCapabilities` has something to\n * report — an actual run refuses the fallback: silently running a normal\n * model on a prompt written for a capability it does not have (an image\n * prompt fed to a text model, say) produces a confusing non-answer instead\n * of a clear error naming the config key to set.\n */\nexport function resolveCapabilityRun(config: WorkersConfig, capability: string): CapabilityRunResolution {\n const r = resolveCapability(config, capability);\n if (r.fellBack) {\n throw new NoProviderError(\n `no provider declares the \"${capability}\" capability — configure one with: ` +\n `pai worker capability ${capability} <provider>[,<provider>…], or ` +\n `pai worker model ${capability} <model-id> --provider <name>`\n );\n }\n // \"image\" specifically promises real pixel output — a provider whose only\n // claim to it is a `models.image` entry from the classes-based idiom\n // (`image: glm/image`, a plain model choice on an unchanged claude/codex\n // engine) cannot deliver that through this cross-provider run path, so a\n // run refuses it rather than spawning claude on a prompt it cannot answer.\n if (capability === \"image\" && r.engine !== \"image\") {\n throw new NoProviderError(\n `provider \"${r.provider}\" has no image engine (it would run \"${r.model}\" on its normal ` +\n `${r.engine} engine, not generate an image) — configure an engine=image provider with: ` +\n `pai worker providers add <name> --engine image --url <url> --key <key> --model <id>, then ` +\n `pai worker capability image <name>`\n );\n }\n const provider = getProviderOrNative(config, r.provider);\n if (!provider) {\n throw new NoProviderError(`capability \"${capability}\" resolved to unknown provider \"${r.provider}\"`);\n }\n return {\n providerName: r.provider,\n provider,\n model: r.model,\n engine: r.engine,\n fellBack: r.fellBack,\n capability,\n };\n}\n","/**\n * server.ts — the PAI worker proxy: Anthropic Messages API on the front,\n * OpenAI Chat Completions on the back, loopback only.\n *\n * One proxy serves every OpenAI-protocol provider: Claude Code points\n * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider\n * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the\n * workers config, re-read per request so config edits apply without a\n * restart). `run` starts the proxy on demand (detached, pid file under the\n * logDir) and `pai worker proxy stop` stops it again.\n *\n * All translation lives in translate.ts; this file is only HTTP plumbing.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createServer, type Server } from \"node:http\";\nimport { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from \"node:fs\";\nimport { connect } from \"node:net\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\nimport { readWorkersSection, resolveProviderKey, type WorkerProvider } from \"../config.js\";\nimport { workersLogDir } from \"../paths.js\";\nimport {\n anthropicError,\n anthropicToOpenAi,\n openAiToAnthropic,\n OpenAiStreamTranslator,\n type AnthropicRequest,\n} from \"./translate.js\";\n\nexport const DEFAULT_PROXY_PORT = 8797;\nconst HOST = \"127.0.0.1\";\n\n/** How providers are looked up — swapped out by the tests. */\nexport type ProviderResolver = () => Record<string, WorkerProvider>;\n\nconst configProviders: ProviderResolver = () => readWorkersSection().workers.providers;\n\nexport function proxyPidPath(logDir: string): string {\n return join(logDir, \"proxy.pid\");\n}\n\n// ---------------------------------------------------------------------------\n// HTTP server\n// ---------------------------------------------------------------------------\n\ninterface ProxyServerOptions {\n port?: number;\n resolveProvider?: ProviderResolver;\n /** Test hook: called with every (translated) upstream request body. */\n onUpstreamRequest?: (url: string, body: unknown) => void;\n}\n\n/**\n * Create (but not start) the proxy server. Routes:\n * GET /healthz → 200 ok\n * POST /<provider>/v1/messages (and /<provider>/messages) → translated call\n */\nexport function createProxyServer(opts: ProxyServerOptions = {}): Server {\n const resolveProvider = opts.resolveProvider ?? configProviders;\n return createServer((req, res) => {\n const url = new URL(req.url ?? \"/\", `http://${HOST}`);\n if (req.method === \"GET\" && url.pathname === \"/healthz\") {\n res.writeHead(200, { \"content-type\": \"text/plain\" });\n res.end(\"ok\");\n return;\n }\n const m = url.pathname.match(/^\\/([a-zA-Z0-9_-]+)\\/(v1\\/)?messages$/);\n if (req.method !== \"POST\" || !m) {\n res.writeHead(404, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(404, `no such route: ${req.method} ${url.pathname}`)));\n return;\n }\n const providerName = m[1];\n const chunks: Buffer[] = [];\n req.on(\"data\", (c: Buffer) => chunks.push(c));\n req.on(\"end\", () => {\n void handleMessages(providerName, Buffer.concat(chunks).toString(\"utf8\"), resolveProvider, res, opts);\n });\n req.on(\"error\", () => {\n res.writeHead(400, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(400, \"request body read failed\")));\n });\n });\n}\n\nasync function handleMessages(\n providerName: string,\n bodyText: string,\n resolveProvider: ProviderResolver,\n res: import(\"node:http\").ServerResponse,\n opts: ProxyServerOptions\n): Promise<void> {\n const fail = (status: number, message: string) => {\n res.writeHead(status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(status, JSON.stringify({ message }))));\n };\n let reqBody: AnthropicRequest;\n try {\n reqBody = JSON.parse(bodyText) as AnthropicRequest;\n } catch {\n fail(400, \"request body is not valid JSON\");\n return;\n }\n const provider = resolveProvider()[providerName];\n if (!provider) {\n fail(404, `no worker provider named \"${providerName}\"`);\n return;\n }\n if (provider.protocol !== \"openai\" || !provider.upstreamUrl) {\n fail(400, `provider \"${providerName}\" is not an openai-protocol provider`);\n return;\n }\n\n const model = provider.models.default;\n const openaiBody = anthropicToOpenAi(reqBody, model);\n opts.onUpstreamRequest?.(`${provider.upstreamUrl}/chat/completions`, openaiBody);\n\n let upstream: Response;\n try {\n const headers: Record<string, string> = { \"content-type\": \"application/json\" };\n let token: string | null;\n try {\n token = resolveProviderKey(provider);\n } catch (e) {\n fail(500, e instanceof Error ? e.message : String(e));\n return;\n }\n if (token) headers.authorization = `Bearer ${token}`;\n upstream = await fetch(`${provider.upstreamUrl}/chat/completions`, {\n method: \"POST\",\n headers,\n body: JSON.stringify(openaiBody),\n });\n } catch (e) {\n fail(502, `upstream unreachable: ${e instanceof Error ? e.message : String(e)}`);\n return;\n }\n\n if (!upstream.ok) {\n const text = await upstream.text().catch(() => \"\");\n res.writeHead(upstream.status, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(anthropicError(upstream.status, text)));\n return;\n }\n\n if (reqBody.stream) {\n res.writeHead(200, {\n \"content-type\": \"text/event-stream\",\n \"cache-control\": \"no-cache\",\n connection: \"keep-alive\",\n });\n const translator = new OpenAiStreamTranslator(model);\n let buf = \"\";\n if (!upstream.body) {\n res.end(translator.finish());\n return;\n }\n const reader = upstream.body.getReader();\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n buf += new TextDecoder().decode(value, { stream: true });\n let nl: number;\n while ((nl = buf.indexOf(\"\\n\")) >= 0) {\n const line = buf.slice(0, nl).trim();\n buf = buf.slice(nl + 1);\n if (!line.startsWith(\"data:\")) continue;\n const payload = line.slice(5).trim();\n if (payload === \"[DONE]\") {\n res.write(translator.finish());\n } else {\n try {\n res.write(translator.feed(JSON.parse(payload) as Parameters<typeof translator.feed>[0]));\n } catch {\n // skip a malformed chunk rather than kill the stream\n }\n }\n }\n }\n } finally {\n res.write(translator.finish()); // no-op when [DONE] already finished it\n res.end();\n }\n return;\n }\n\n const json = (await upstream.json().catch(() => null)) as unknown;\n if (!json) {\n fail(502, \"upstream returned a non-JSON body\");\n return;\n }\n res.writeHead(200, { \"content-type\": \"application/json\" });\n res.end(JSON.stringify(openAiToAnthropic(json as Parameters<typeof openAiToAnthropic>[0], model)));\n}\n\n// ---------------------------------------------------------------------------\n// Start / stop\n// ---------------------------------------------------------------------------\n\n/** Listen on host:port (loopback). Resolves with the bound port. */\nexport function listenProxy(server: Server, port = DEFAULT_PROXY_PORT): Promise<number> {\n return new Promise((resolve, reject) => {\n server.once(\"error\", reject);\n server.listen(port, HOST, () => {\n const addr = server.address();\n resolve(typeof addr === \"object\" && addr ? addr.port : port);\n });\n });\n}\n\n/** True when something already accepts connections on the port. */\nexport function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400): Promise<boolean> {\n return new Promise((resolve) => {\n const sock = connect({ port, host: HOST });\n const done = (ok: boolean) => {\n sock.removeAllListeners();\n sock.destroy();\n resolve(ok);\n };\n sock.setTimeout(timeoutMs, () => done(false));\n sock.once(\"connect\", () => done(true));\n sock.once(\"error\", () => done(false));\n });\n}\n\n/**\n * Where the standalone proxy artefact lives, found from this module's own\n * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).\n */\nexport function standaloneProxyPath(): string | null {\n let dir = dirname(fileURLToPath(import.meta.url));\n for (let i = 0; i < 6; i++) {\n for (const cand of [\n join(dir, \"worker-proxy.mjs\"),\n join(dir, \"hooks\", \"worker-proxy.mjs\"),\n join(dir, \"dist\", \"hooks\", \"worker-proxy.mjs\"),\n ]) {\n if (existsSync(cand)) return cand;\n }\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/**\n * Ensure a proxy is listening on the port; start the detached standalone when\n * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).\n */\nexport async function ensureProxyRunning(port: number, logDir: string): Promise<string> {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n const script = standaloneProxyPath();\n if (!script) {\n throw new Error(\n `the PAI worker proxy is not running and its build was not found — ` +\n `run \\`bun run build\\` (or start one with: pai worker proxy --port ${port})`\n );\n }\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n const child = spawn(process.execPath, [script, \"--port\", String(port)], {\n detached: true,\n stdio: \"ignore\",\n });\n child.unref();\n writeFileSync(proxyPidPath(logDir), `${child.pid}\\n`, \"utf8\");\n for (let i = 0; i < 40; i++) {\n if (await proxyListening(port)) return `http://${HOST}:${port}`;\n await new Promise((r) => setTimeout(r, 250));\n }\n throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);\n}\n\n/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */\nexport function stopProxy(logDir: string): string {\n const path = proxyPidPath(logDir);\n if (!existsSync(path)) return \"no proxy pid file — nothing to stop\";\n const pid = parseInt(readFileSync(path, \"utf8\").trim(), 10);\n unlinkSync(path);\n if (!Number.isFinite(pid)) return \"stale proxy pid file removed\";\n try {\n process.kill(pid, \"SIGTERM\");\n } catch {\n return `proxy pid ${pid} was not running (pid file removed)`;\n }\n return `proxy pid ${pid} stopped`;\n}\n"],"mappings":"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"}
@@ -1,9 +1,9 @@
1
- import { a as paiHomePath } from "./pai-home-CeOIYltV.mjs";
2
- import { y as writeJsonAtomic } from "./config-CQCmH_gV.mjs";
3
- import { E as readWorkersSection } from "./run-env-BE5xyCwy.mjs";
4
- import { A as appendLedger, k as worktreesDir, x as sessionMapPath, y as itermUuid } from "./run-BVQZI79D.mjs";
5
- import { v as workersLogDir } from "./server-BY6AedmC.mjs";
6
- import { i as sendToSession, n as fetchLiveSessions } from "./aibroker-client-BK1h--W_.mjs";
1
+ import { a as paiHomePath } from "./pai-home-UncxWxlX.mjs";
2
+ import { y as writeJsonAtomic } from "./config-2wkz744W.mjs";
3
+ import { E as readWorkersSection } from "./run-env-Br4Bc7Vz.mjs";
4
+ import { O as worktreesDir, b as sessionMapPath, k as appendLedger, v as itermUuid } from "./run-W2rV_9j0.mjs";
5
+ import { v as workersLogDir } from "./server-B71rem4q.mjs";
6
+ import { i as sendToSession, n as fetchLiveSessions } from "./aibroker-client-DRld7g6z.mjs";
7
7
  import { createReadStream, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
@@ -579,4 +579,4 @@ async function runSessionKeepaliveTick(config, overrides = {}) {
579
579
 
580
580
  //#endregion
581
581
  export { totalUsageTokens as a, parseSessionUsage as i, runSessionKeepaliveTick as n, sessionKeepaliveLedgerSummary as r, loadSessionKeepaliveState as t };
582
- //# sourceMappingURL=session-keepalive-DJA97K2X.mjs.map
582
+ //# sourceMappingURL=session-keepalive-Dgil9hjw.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-keepalive-DJA97K2X.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":"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"}
1
+ {"version":3,"file":"session-keepalive-Dgil9hjw.mjs","names":["fetchLiveSessionsDefault","sendToSessionDefault"],"sources":["../src/audit/session-usage.ts","../src/daemon/session-keepalive.ts"],"sourcesContent":["/**\n * session-usage.ts — parse the usage numbers out of a Claude Code session\n * (or subagent, or pai-worker event-mirror) JSONL transcript.\n *\n * All three log shapes share the same assistant-message envelope\n * ({ type: \"assistant\", message: { id, model, usage } }), so one parser\n * covers `pai audit tokens session` and the per-log readings feeding\n * `pai audit tokens spawn`.\n *\n * Streaming writes one JSONL line per content block of the same logical\n * turn, repeating message.id and usage each time — summing every line would\n * multiply usage by the block count. Keeping only the first line seen per\n * message.id is what the reference script (and this parser) count instead.\n */\n\nimport { createReadStream, existsSync } from \"node:fs\";\nimport { createInterface } from \"node:readline\";\nimport { readFileSync } from \"node:fs\";\n\nexport interface UsageTotals {\n cache_read_input_tokens: number;\n cache_creation_input_tokens: number;\n input_tokens: number;\n output_tokens: number;\n}\n\nexport interface CacheCreationSplit {\n ephemeral5m: number;\n ephemeral1h: number;\n}\n\nexport interface CompactionEvent {\n trigger: string;\n preTokens: number;\n turnIndex: number;\n}\n\nexport interface ModelSwitch {\n turnIndex: number;\n from: string;\n to: string;\n cacheRead: number;\n cacheCreation: number;\n}\n\nexport interface FallbackEvent {\n turnIndex: number;\n from: string;\n to: string;\n category: string;\n scope: string;\n}\n\nexport interface SessionUsageReport {\n path: string;\n sizeBytes: number;\n turns: number;\n totals: UsageTotals;\n /** Per-model assistant-turn counts. */\n models: Record<string, number>;\n /** cache_read + cache_creation + input on the first / last assistant turn seen. */\n firstTurnContext: number | null;\n lastTurnContext: number | null;\n cacheCreationSplit: CacheCreationSplit;\n /** Average / max of the per-turn context value across all turns; null if no turns. */\n avgContext: number | null;\n maxContext: number | null;\n /** Turns whose per-turn context value exceeds the report's threshold. */\n turnsAboveThreshold: number;\n /** Turns whose cache_creation_input_tokens exceeds 20000. */\n cacheRebuildTurns: number;\n /** Real human-authored user prompts (excludes tool-result-only \"user\" lines). */\n userPrompts: number;\n /**\n * Sum over assistant turns of the real user prompts seen before that turn;\n * multiplied by per-prompt hook tokens it gives the tokens UserPromptSubmit\n * output occupied across the whole session.\n */\n promptExposure: number;\n compactions: CompactionEvent[];\n /** ISO timestamp of the first assistant turn. */\n firstTurnAt: string | null;\n /** Model of the last folded turn; drives switch detection. */\n lastModel: string | null;\n modelSwitches: ModelSwitch[];\n fallbacks: FallbackEvent[];\n /** Timestamp (ms) of the last folded assistant turn; drives idle-gap detection. */\n lastTurnAtMs: number | null;\n /** Gaps between consecutive assistant turns exceeding 60 minutes — evidence\n * for whether the session ever went idle long enough to risk its cache TTL\n * (see sessions.cacheKeepalive, src/daemon/config.ts). */\n idleGapsOver60min: number;\n /** Real user prompts whose text is exactly the configured keepalive word\n * (set only when parseSessionUsage is called with one); null otherwise. */\n keepaliveBeats: number | null;\n}\n\nconst USAGE_KEYS: (keyof UsageTotals)[] = [\n \"cache_read_input_tokens\",\n \"cache_creation_input_tokens\",\n \"input_tokens\",\n \"output_tokens\",\n];\n\nfunction emptyTotals(): UsageTotals {\n return { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, input_tokens: 0, output_tokens: 0 };\n}\n\nexport interface AssistantLine {\n type?: string;\n uuid?: string;\n subtype?: string;\n /** Claude Code marks skill expansions and injected system reminders isMeta; they fire no UserPromptSubmit hook. */\n isMeta?: boolean;\n /** Present on newer logs: { kind: \"human\" } for a typed prompt. */\n origin?: { kind?: string };\n compactMetadata?: { trigger?: string; preTokens?: number };\n timestamp?: string;\n originalModel?: string;\n fallbackModel?: string;\n apiRefusalCategory?: string;\n scope?: string;\n message?: {\n id?: string;\n model?: string;\n usage?: Record<string, unknown> & {\n cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };\n };\n content?: string | Array<{ type?: string }>;\n stop_reason?: string | null;\n };\n}\n\n/**\n * `type:\"system\"` `subtype:\"compact_boundary\"` lines mark a compaction.\n * turnIndex is the count of assistant turns already folded when it fired,\n * so it lines up with the turn numbering the text/JSON report prints.\n */\nexport function isCompactBoundary(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"compact_boundary\";\n}\n\nexport function parseCompactionEvent(line: AssistantLine, turnIndex: number): CompactionEvent | null {\n const meta = line.compactMetadata;\n if (!meta || typeof meta.preTokens !== \"number\") return null;\n return { trigger: meta.trigger ?? \"unknown\", preTokens: meta.preTokens, turnIndex };\n}\n\n/**\n * `type:\"system\"` `subtype:\"model_refusal_fallback\"` lines mark an\n * automatic model switch fired by an API safety refusal (e.g. \"cyber\"\n * category), not a user or config choice — worth flagging separately since\n * it can rebuild the whole prompt cache mid-session.\n */\nexport function isModelFallback(line: AssistantLine): boolean {\n return line.type === \"system\" && line.subtype === \"model_refusal_fallback\";\n}\n\nexport function parseFallbackEvent(line: AssistantLine, turnIndex: number): FallbackEvent {\n return {\n turnIndex,\n from: line.originalModel ?? \"unknown\",\n to: line.fallbackModel ?? \"unknown\",\n category: line.apiRefusalCategory ?? \"unknown\",\n scope: line.scope ?? \"unknown\",\n };\n}\n\n/**\n * Fold one already-parsed JSONL line into a report being accumulated.\n * Exported separately so both the streaming file reader below and tests\n * (which build fixtures as arrays of objects, not files) share one path.\n * Returns the turn's context value when a new turn was counted, else null\n * (non-assistant line, no usage, or a duplicate message.id already seen).\n */\nexport function foldAssistantLine(\n report: Pick<\n SessionUsageReport,\n | \"turns\"\n | \"totals\"\n | \"models\"\n | \"firstTurnContext\"\n | \"lastTurnContext\"\n | \"cacheCreationSplit\"\n | \"maxContext\"\n | \"turnsAboveThreshold\"\n | \"cacheRebuildTurns\"\n | \"firstTurnAt\"\n | \"lastModel\"\n | \"modelSwitches\"\n | \"lastTurnAtMs\"\n | \"idleGapsOver60min\"\n >,\n seenIds: Set<string>,\n line: AssistantLine,\n threshold: number\n): number | null {\n if (line.type !== \"assistant\") return null;\n const message = line.message;\n const usage = message?.usage;\n if (!usage) return null;\n const id = message?.id ?? line.uuid;\n if (id) {\n if (seenIds.has(id)) return null;\n seenIds.add(id);\n }\n report.turns++;\n for (const key of USAGE_KEYS) {\n const v = usage[key];\n if (typeof v === \"number\") report.totals[key] += v;\n }\n const context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n if (report.firstTurnContext === null) {\n report.firstTurnContext = context;\n report.firstTurnAt = line.timestamp ?? null;\n }\n const atMs = line.timestamp ? Date.parse(line.timestamp) : NaN;\n if (!Number.isNaN(atMs)) {\n if (report.lastTurnAtMs !== null && atMs - report.lastTurnAtMs > 60 * 60 * 1000) {\n report.idleGapsOver60min++;\n }\n report.lastTurnAtMs = atMs;\n }\n report.lastTurnContext = context;\n report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);\n if (context > threshold) report.turnsAboveThreshold++;\n if ((Number(usage.cache_creation_input_tokens) || 0) > 20000) report.cacheRebuildTurns++;\n const model = message?.model ?? \"unknown\";\n report.models[model] = (report.models[model] ?? 0) + 1;\n if (report.lastModel !== null && report.lastModel !== model) {\n report.modelSwitches.push({\n turnIndex: report.turns,\n from: report.lastModel,\n to: model,\n cacheRead: Number(usage.cache_read_input_tokens) || 0,\n cacheCreation: Number(usage.cache_creation_input_tokens) || 0,\n });\n }\n report.lastModel = model;\n const split = usage.cache_creation;\n if (split) {\n report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;\n report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;\n }\n return context;\n}\n\n/**\n * True for a real human-authored `type:\"user\"` prompt line: content is a\n * plain string, or a content array with no `tool_result` block. Claude Code\n * encodes tool results as `type:\"user\"` messages whose content array is\n * entirely (or partly) tool_result blocks — those must not count as prompts.\n */\nexport function isRealUserPrompt(line: AssistantLine): boolean {\n if (line.type !== \"user\") return false;\n if (line.isMeta) return false;\n if (line.origin?.kind && line.origin.kind !== \"human\") return false;\n const content = line.message?.content;\n if (typeof content === \"string\") return true;\n if (Array.isArray(content)) return !content.some((block) => block?.type === \"tool_result\");\n return false;\n}\n\n/** Plain text of a real user prompt: the string content, or its first text block. */\nexport function extractUserPromptText(line: AssistantLine): string {\n const content = line.message?.content;\n if (typeof content === \"string\") return content;\n if (Array.isArray(content)) {\n const block = content.find((b) => b?.type === \"text\") as { text?: string } | undefined;\n return block?.text ?? \"\";\n }\n return \"\";\n}\n\n/**\n * Parse a session/subagent/worker-event JSONL file into a usage report.\n * `keepaliveWord`, when given, counts real user prompts whose text exactly\n * matches it (trimmed) — the sessions.cacheKeepalive beat prompt — into\n * `keepaliveBeats`; omitted, `keepaliveBeats` stays null.\n */\nexport async function parseSessionUsage(\n path: string,\n threshold = 200_000,\n keepaliveWord?: string\n): Promise<SessionUsageReport> {\n const report: SessionUsageReport = {\n path,\n sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,\n turns: 0,\n totals: emptyTotals(),\n models: {},\n firstTurnContext: null,\n lastTurnContext: null,\n cacheCreationSplit: { ephemeral5m: 0, ephemeral1h: 0 },\n avgContext: null,\n maxContext: null,\n turnsAboveThreshold: 0,\n cacheRebuildTurns: 0,\n userPrompts: 0,\n promptExposure: 0,\n compactions: [],\n firstTurnAt: null,\n lastModel: null,\n modelSwitches: [],\n fallbacks: [],\n lastTurnAtMs: null,\n idleGapsOver60min: 0,\n keepaliveBeats: keepaliveWord ? 0 : null,\n };\n const seenIds = new Set<string>();\n let contextSum = 0;\n\n const rl = createInterface({ input: createReadStream(path, \"utf8\"), crlfDelay: Infinity });\n for await (const raw of rl) {\n if (!raw.trim()) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (obj.type === \"user\") {\n if (isRealUserPrompt(obj)) {\n report.userPrompts++;\n if (keepaliveWord && extractUserPromptText(obj).trim() === keepaliveWord) {\n report.keepaliveBeats = (report.keepaliveBeats ?? 0) + 1;\n }\n }\n continue;\n }\n if (isCompactBoundary(obj)) {\n const event = parseCompactionEvent(obj, report.turns);\n if (event) report.compactions.push(event);\n continue;\n }\n if (isModelFallback(obj)) {\n report.fallbacks.push(parseFallbackEvent(obj, report.turns));\n continue;\n }\n const context = foldAssistantLine(report, seenIds, obj, threshold);\n if (context !== null) {\n contextSum += context;\n report.promptExposure += report.userPrompts;\n }\n }\n report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;\n return report;\n}\n\nexport function totalUsageTokens(totals: UsageTotals): number {\n return totals.cache_read_input_tokens + totals.cache_creation_input_tokens + totals.input_tokens + totals.output_tokens;\n}\n","/**\n * session-keepalive.ts — idle-triggered prompt-cache keepalive beat for live\n * interactive Claude Code sessions (`sessions.cacheKeepalive`, see config.ts\n * and docs/cache-keepalive.md, \"Interactive sessions\").\n *\n * One beat = one trivial prompt typed into a session through AIBroker's\n * send_to_session (src/cli/lib/aibroker-client.ts), sent with `noReply: true`\n * so the target sees only the bare word typed into its input line and\n * nothing is queued into its mailbox as a peer message demanding a reply.\n * A cache READ refreshes the provider's ephemeral prompt-cache TTL at\n * roughly 0.1x the cost of the 2x rewrite a cold cache forces on the next\n * real prompt.\n *\n * Distinct from workers/keepalive.ts, which beats a *worker provider's*\n * cache on a fixed timer regardless of activity: this only beats a session\n * that has actually been idle long enough to be at risk, and only inside a\n * configured working-hours window, so it never fires while the user is\n * present at the keyboard or overnight when nobody will read the reply\n * before the cache would have expired anyway.\n */\n\nimport { existsSync, readFileSync, readdirSync, statSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport {\n fetchLiveSessions as fetchLiveSessionsDefault,\n sendToSession as sendToSessionDefault,\n type AiBrokerSessionMeta,\n} from \"../cli/lib/aibroker-client.js\";\nimport { writeJsonAtomic } from \"../config/json-store.js\";\nimport { paiHomePath } from \"../config/pai-home.js\";\nimport { appendLedger } from \"../workers/ledger.js\";\nimport { readWorkersSection } from \"../workers/config.js\";\nimport { workersLogDir } from \"../workers/paths.js\";\nimport { worktreesDir } from \"../workers/worktree.js\";\nimport { itermUuid, sessionMapPath, type SessionMapEntry } from \"../workers/scope.js\";\nimport { isRealUserPrompt, extractUserPromptText, type AssistantLine } from \"../audit/session-usage.js\";\nimport type { SessionsCacheKeepaliveConfig } from \"./config.js\";\n\n// ---------------------------------------------------------------------------\n// State file — per-session beat counters, rebuildable (see json-store.ts on\n// when NOT to use readJsonStrict: a damaged file here just resets counters,\n// never blocks the feature).\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveEntry {\n /** Beats sent since the last real (non-keepalive) user prompt. */\n beats: number;\n /** Identity of the last real user prompt observed, so a fresh one can be\n * told apart from the keepalive's own echo landing back in the transcript. */\n lastRealPromptKey: string | null;\n /** ISO stamp of the last beat sent. */\n lastBeatAt: string | null;\n}\n\nexport type SessionKeepaliveState = Record<string, SessionKeepaliveEntry>;\n\nexport function sessionKeepaliveStatePath(): string {\n return paiHomePath(\"session-keepalive.json\");\n}\n\nfunction emptyEntry(): SessionKeepaliveEntry {\n return { beats: 0, lastRealPromptKey: null, lastBeatAt: null };\n}\n\nexport function loadSessionKeepaliveState(path: string = sessionKeepaliveStatePath()): SessionKeepaliveState {\n if (!existsSync(path)) return {};\n try {\n return JSON.parse(readFileSync(path, \"utf8\")) as SessionKeepaliveState;\n } catch {\n return {};\n }\n}\n\nexport function saveSessionKeepaliveState(\n state: SessionKeepaliveState,\n path: string = sessionKeepaliveStatePath()\n): void {\n writeJsonAtomic(path, state, { backup: false, label: path });\n}\n\n// ---------------------------------------------------------------------------\n// Active-hours window\n// ---------------------------------------------------------------------------\n\n/** Parse \"HH:MM-HH:MM\" into minutes-since-midnight. Throws on a malformed\n * spec — an explicit config error beats a window that is silently always\n * on or always off. */\nexport function parseActiveHours(spec: string): { startMin: number; endMin: number } {\n const m = spec.match(/^(\\d{1,2}):(\\d{2})-(\\d{1,2}):(\\d{2})$/);\n if (!m) {\n throw new Error(`sessions.cacheKeepalive.activeHours: invalid \"${spec}\" (want \"HH:MM-HH:MM\")`);\n }\n return {\n startMin: Number(m[1]) * 60 + Number(m[2]),\n endMin: Number(m[3]) * 60 + Number(m[4]),\n };\n}\n\n/**\n * Whether `now` (local time) sits inside the window. A window that wraps\n * midnight (e.g. \"22:00-06:00\") is honoured by inverting the test instead of\n * requiring startMin < endMin.\n */\nexport function isWithinActiveHours(now: Date, spec: string): boolean {\n const { startMin, endMin } = parseActiveHours(spec);\n const nowMin = now.getHours() * 60 + now.getMinutes();\n if (startMin <= endMin) return nowMin >= startMin && nowMin < endMin;\n return nowMin >= startMin || nowMin < endMin;\n}\n\n// ---------------------------------------------------------------------------\n// Transcript lookup\n// ---------------------------------------------------------------------------\n\n/**\n * Full path to a live session's transcript under ~/.claude/projects, or null\n * when none is found — the case for a session too new to have written a file\n * yet. A worker running in a worktree DOES write a transcript here (Claude\n * Code encodes its worktree cwd as the project dir name), so workers are not\n * excluded \"for free\" — see isWorkerSession.\n */\nexport function findSessionTranscript(\n sessionId: string,\n projectsDir: string = join(homedir(), \".claude\", \"projects\")\n): string | null {\n let projectDirs: string[];\n try {\n projectDirs = readdirSync(projectsDir);\n } catch {\n return null;\n }\n for (const projectDir of projectDirs) {\n const candidate = join(projectsDir, projectDir, `${sessionId}.jsonl`);\n if (existsSync(candidate)) return candidate;\n }\n return null;\n}\n\n/**\n * AIBroker's `fetchLiveSessions()` identifies a session by its iTerm2 pane id\n * (e.g. an AIBroker/iTerm UUID), not the Claude session id `<uuid>.jsonl`\n * transcripts are named after — the two are unrelated identifiers. The\n * status line bridges them on every refresh via `claude-session-map.json`\n * (see `recordSessionMapEntry` in ../workers/scope.ts): this picks, among the\n * map entries whose `term` pane UUID matches, the most recently written one.\n * Returns null when the pane has no (fresh enough) mapped Claude session.\n */\nexport function resolveClaudeSessionIdFromMap(paneId: string, logDir: string): string | null {\n const path = sessionMapPath(logDir);\n if (!paneId || !existsSync(path)) return null;\n let map: Record<string, SessionMapEntry>;\n try {\n map = JSON.parse(readFileSync(path, \"utf8\")) as Record<string, SessionMapEntry>;\n } catch {\n return null;\n }\n let best: SessionMapEntry | null = null;\n for (const entry of Object.values(map)) {\n if (!entry.term || itermUuid(entry.term) !== paneId) continue;\n if (!best || entry.ts > best.ts) best = entry;\n }\n return best?.session ?? null;\n}\n\n/** Claude Code's project-dir encoding of a cwd: every \"/\" becomes \"-\". */\nfunction encodeProjectDirName(cwd: string): string {\n return cwd.replace(/\\//g, \"-\");\n}\n\n/**\n * Is this live \"claude\"-kind session actually a worker pane rather than an\n * interactive one? `claude -p` workers running in a worktree write their\n * transcript under ~/.claude/projects too (encoded cwd = the worktree dir),\n * so a missing transcript is NOT how workers get excluded — this predicate\n * is. Either signal is enough:\n * (a) the transcript's project-dir name is the encoded form of a path\n * under <logDir>/worktrees (every worker worktree lives there), or\n * (b) the broker's session name/paiName names a path under the workers\n * log dir or one of its worktrees (best-effort: AIBroker does not\n * guarantee this, but honors it when present).\n */\nexport function isWorkerSession(\n meta: AiBrokerSessionMeta,\n transcriptPath: string | null,\n logDir: string\n): boolean {\n const worktreesPrefix = encodeProjectDirName(worktreesDir(logDir));\n if (transcriptPath) {\n const projectDirName = transcriptPath.split(\"/\").slice(0, -1).pop() ?? \"\";\n if (projectDirName.startsWith(worktreesPrefix)) return true;\n }\n const rawPrefix = worktreesDir(logDir);\n for (const field of [meta.name, meta.paiName]) {\n if (field && (field.includes(rawPrefix) || field.includes(logDir))) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Tail snapshot: last turn's context, mid-turn flag, last real prompt\n// ---------------------------------------------------------------------------\n\nexport interface TranscriptSnapshot {\n /** cache_read + cache_creation + input on the most recent assistant turn seen. */\n context: number | null;\n /** True when the last line in the file shows the session still working: an\n * assistant turn with no usage yet (streaming) or a stop_reason other than\n * end_turn/stop_sequence (e.g. mid tool-call), or a real user prompt with\n * no reply behind it yet. A beat sent now would land mid-generation. */\n midTurn: boolean;\n /** The last real (human-authored) user prompt seen, if any. */\n lastRealPrompt: { key: string; text: string } | null;\n}\n\nfunction isMidTurn(line: AssistantLine | null): boolean {\n if (!line) return false;\n if (line.type === \"user\" && isRealUserPrompt(line)) return true;\n if (line.type === \"assistant\") {\n const stopReason = line.message?.stop_reason;\n if (stopReason === undefined || stopReason === null) return true;\n return stopReason !== \"end_turn\" && stopReason !== \"stop_sequence\";\n }\n return false;\n}\n\nexport function readTranscriptSnapshot(path: string): TranscriptSnapshot {\n let lines: string[];\n try {\n lines = readFileSync(path, \"utf8\").split(\"\\n\");\n } catch {\n return { context: null, midTurn: false, lastRealPrompt: null };\n }\n\n let lastLine: AssistantLine | null = null;\n let context: number | null = null;\n let lastRealPrompt: { key: string; text: string } | null = null;\n\n for (let i = lines.length - 1; i >= 0; i--) {\n const raw = lines[i].trim();\n if (!raw) continue;\n let obj: AssistantLine;\n try {\n obj = JSON.parse(raw) as AssistantLine;\n } catch {\n continue;\n }\n if (lastLine === null) lastLine = obj;\n\n if (context === null && obj.type === \"assistant\" && obj.message?.usage) {\n const usage = obj.message.usage;\n context =\n (Number(usage.cache_read_input_tokens) || 0) +\n (Number(usage.cache_creation_input_tokens) || 0) +\n (Number(usage.input_tokens) || 0);\n }\n\n if (lastRealPrompt === null && isRealUserPrompt(obj)) {\n const key = obj.uuid ?? obj.timestamp ?? String(i);\n lastRealPrompt = { key, text: extractUserPromptText(obj).trim() };\n }\n\n if (context !== null && lastRealPrompt !== null) break;\n }\n\n return { context, midTurn: isMidTurn(lastLine), lastRealPrompt };\n}\n\n// ---------------------------------------------------------------------------\n// Ledger — same file WORKER-KEEPALIVE lines go to (workers.logDir/ledger.log)\n// ---------------------------------------------------------------------------\n\n/** Ledger event tag; `pai daemon keepalive` and any log-tailer key off this. */\nexport const SESSION_KEEPALIVE_EVENT = \"SESSION-KEEPALIVE\";\n\nexport function sessionKeepaliveLedgerPath(): string {\n const { workers } = readWorkersSection();\n return join(workersLogDir(workers), \"ledger.log\");\n}\n\n/** The counts/last lines `pai daemon keepalive` prints, scoped to this event. */\nexport function sessionKeepaliveLedgerSummary(\n path: string = sessionKeepaliveLedgerPath(),\n lastN = 10\n): { sent: number; skipped: number; lastLines: string[] } {\n if (!existsSync(path)) return { sent: 0, skipped: 0, lastLines: [] };\n const lines = readFileSync(path, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.includes(SESSION_KEEPALIVE_EVENT));\n const sent = lines.filter((l) => / result=sent(\\s|$)/.test(l)).length;\n return { sent, skipped: lines.length - sent, lastLines: lines.slice(-lastN) };\n}\n\n// ---------------------------------------------------------------------------\n// The tick\n// ---------------------------------------------------------------------------\n\nexport interface SessionKeepaliveDeps {\n now: () => Date;\n fetchLiveSessions: () => Promise<AiBrokerSessionMeta[]>;\n /** AIBroker pane id (fetchLiveSessions' `sessionId`) -> Claude transcript\n * session id, via the status line's claude-session-map.json bridge. */\n resolveClaudeSessionId: (paneId: string) => string | null;\n findTranscript: (sessionId: string) => string | null;\n mtimeMs: (path: string) => number | null;\n readSnapshot: (path: string) => TranscriptSnapshot;\n sendBeat: (sessionId: string, text: string) => Promise<{ ok: boolean; error?: string }>;\n loadState: () => SessionKeepaliveState;\n saveState: (s: SessionKeepaliveState) => void;\n ledger: (kv: Record<string, string | number | null | undefined>) => void;\n}\n\nexport interface SessionKeepaliveResult {\n sessionId: string;\n result: \"sent\" | string; // \"skipped:<reason>\"\n}\n\nfunction defaultDeps(): SessionKeepaliveDeps {\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n return {\n now: () => new Date(),\n fetchLiveSessions: fetchLiveSessionsDefault,\n resolveClaudeSessionId: (paneId) => resolveClaudeSessionIdFromMap(paneId, logDir),\n findTranscript: (id) => findSessionTranscript(id),\n mtimeMs: (p) => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return null;\n }\n },\n readSnapshot: readTranscriptSnapshot,\n sendBeat: (id, text) => sendToSessionDefault(id, text, undefined, { noReply: true }),\n loadState: () => loadSessionKeepaliveState(),\n saveState: (s) => saveSessionKeepaliveState(s),\n ledger: (kv) => appendLedger(sessionKeepaliveLedgerPath(), SESSION_KEEPALIVE_EVENT, kv),\n };\n}\n\n/**\n * One tick over every live interactive session: beat the ones that qualify,\n * skip (with a reason) the ones that don't. Returns one result per live\n * \"claude\"-kind session for tests to assert against; writes the ledger line\n * and (when any counter changed) the state file as side effects.\n */\nexport async function runSessionKeepaliveTick(\n config: SessionsCacheKeepaliveConfig,\n overrides: Partial<SessionKeepaliveDeps> = {}\n): Promise<SessionKeepaliveResult[]> {\n if (!config.enabled) return [];\n\n const d: SessionKeepaliveDeps = { ...defaultDeps(), ...overrides };\n const sessions = await d.fetchLiveSessions();\n const state = d.loadState();\n const now = d.now();\n const { workers } = readWorkersSection();\n const logDir = workersLogDir(workers);\n const results: SessionKeepaliveResult[] = [];\n let anyChanged = false;\n\n for (const s of sessions) {\n if (s.kind !== \"claude\") continue;\n const paneId = s.sessionId;\n\n const claudeId = d.resolveClaudeSessionId(paneId);\n if (!claudeId) {\n d.ledger({ session: null, pane: paneId, result: \"skipped:unmapped\" });\n results.push({ sessionId: paneId, result: \"skipped:unmapped\" });\n continue;\n }\n const sessionId = claudeId;\n const entry = { ...(state[sessionId] ?? emptyEntry()) };\n let changed = false;\n\n const transcript = d.findTranscript(sessionId);\n\n if (isWorkerSession(s, transcript, logDir)) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:worker\" });\n results.push({ sessionId, result: \"skipped:worker\" });\n continue;\n }\n\n if (!transcript) {\n d.ledger({ session: sessionId, pane: paneId, result: \"skipped:no-transcript\" });\n results.push({ sessionId, result: \"skipped:no-transcript\" });\n continue;\n }\n\n const snapshot = d.readSnapshot(transcript);\n\n // A fresh real prompt (not the keepalive's own echo) resets the beat\n // count — the idle stretch it capped is over.\n if (snapshot.lastRealPrompt && snapshot.lastRealPrompt.key !== entry.lastRealPromptKey) {\n entry.lastRealPromptKey = snapshot.lastRealPrompt.key;\n if (snapshot.lastRealPrompt.text !== config.prompt) entry.beats = 0;\n changed = true;\n }\n\n const mtime = d.mtimeMs(transcript);\n const idleMin = mtime === null ? null : (now.getTime() - mtime) / 60_000;\n\n const skip: string | null =\n mtime === null || idleMin === null\n ? \"no-mtime\"\n : idleMin < config.idleMinutes\n ? `idle:${idleMin.toFixed(1)}min`\n : !isWithinActiveHours(now, config.activeHours)\n ? \"hours\"\n : snapshot.context === null || snapshot.context < config.minContextTokens\n ? \"context\"\n : snapshot.midTurn\n ? \"mid-turn\"\n : entry.beats >= config.maxBeats\n ? \"max-beats\"\n : null;\n\n const ledgerBase = {\n session: sessionId,\n pane: paneId,\n idle_min: idleMin === null ? null : idleMin.toFixed(1),\n context: snapshot.context,\n };\n\n if (skip) {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: `skipped:${skip}` });\n results.push({ sessionId, result: `skipped:${skip}` });\n } else {\n const sent = await d.sendBeat(paneId, config.prompt);\n if (sent.ok) {\n entry.beats += 1;\n entry.lastBeatAt = now.toISOString();\n changed = true;\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"sent\" });\n results.push({ sessionId, result: \"sent\" });\n } else {\n d.ledger({ ...ledgerBase, beat: `${entry.beats}/${config.maxBeats}`, result: \"skipped:send-failed\" });\n results.push({ sessionId, result: \"skipped:send-failed\" });\n }\n }\n\n if (changed) {\n state[sessionId] = entry;\n anyChanged = true;\n }\n }\n\n if (anyChanged) d.saveState(state);\n return results;\n}\n\n"],"mappings":"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"}
@@ -114,4 +114,4 @@ function tail(s, n) {
114
114
 
115
115
  //#endregion
116
116
  export { cmdMemorySources };
117
- //# sourceMappingURL=sources-BBe3AZ-W.mjs.map
117
+ //# sourceMappingURL=sources-LcGptLA-.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"sources-BBe3AZ-W.mjs","names":[],"sources":["../src/cli/commands/memory/sources.ts"],"sourcesContent":["/**\n * `pai memory sources` — what the indexer is actually taking in.\n *\n * The index had grown to ~1.5M chunks against a vault of ~2,700 notes, and\n * nothing in the CLI could show why. `memory status` refuses to report at all\n * when the backend is not SQLite, and `daemon status` gives three totals with no\n * composition — so answering \"what is it indexing, and why is it re-indexing\"\n * meant hand-writing aggregate SQL against the container.\n *\n * The cause, when it was finally measured, was visible in one breakdown: the\n * vault indexer follows symlinks out of the vault into cloud-synced trees, and\n * those trees have mtimes rewritten by the sync client on content that has not\n * changed. Each rewrite re-chunks the file, and re-chunking assigns new chunk\n * ids, which discards the embeddings — so the embedder was re-doing work\n * indefinitely while never catching up.\n *\n * Hence the four sections below. Each one exists because it was needed:\n * composition — the vault dwarfing everything else is the first clue\n * roots — where content enters from, which is how symlink leakage shows\n * heaviest — single files contributing thousands of chunks (attachments)\n * churn — chunks rewritten per day, which is what distinguishes a\n * backlog that will finish from a treadmill that never will\n */\n\nimport { ok, warn, dim, bold, header, renderTable } from \"../../utils.js\";\nimport type { StorageBackend } from \"../../../storage/interface.js\";\n\nconst num = (v: unknown): number => Number(v ?? 0);\nconst pct = (part: number, whole: number): string =>\n whole === 0 ? \"—\" : `${Math.round((part / whole) * 100)}%`;\n\n/** Group the leading path segments, which is where content enters the index. */\nexport function rootOf(path: string, depth = 2): string {\n const parts = path.split(\"/\").filter(Boolean);\n if (parts.length <= depth) return path;\n return parts.slice(0, depth).join(\"/\") + \"/…\";\n}\n\nexport async function cmdMemorySources(\n backend: StorageBackend,\n opts: { limit?: number } = {}\n): Promise<void> {\n const limit = opts.limit ?? 8;\n\n const report = await backend.getMemorySourcesReport();\n const comp = report.composition;\n\n if (comp.length === 0) {\n console.log();\n console.log(warn(` Nothing indexed yet, or the backend is unreachable.`));\n console.log(dim(` Backend: ${backend.backendType}`));\n console.log();\n return;\n }\n\n const totalChunks = comp.reduce((s, r) => s + num(r.chunks), 0);\n const totalEmbedded = comp.reduce((s, r) => s + num(r.embedded), 0);\n\n console.log();\n console.log(header(`What the indexer has taken in`));\n console.log();\n console.log(\n ` ${bold(totalChunks.toLocaleString())} chunks ` +\n `${totalEmbedded.toLocaleString()} embedded (${pct(totalEmbedded, totalChunks)}) ` +\n dim(`backend: ${backend.backendType}`)\n );\n console.log();\n\n console.log(\n renderTable(\n [\"source / tier\", \"chunks\", \"share\", \"embedded\"],\n comp.map((r) => [\n `${r.source} / ${r.tier}`,\n num(r.chunks).toLocaleString(),\n pct(num(r.chunks), totalChunks),\n `${num(r.embedded).toLocaleString()} (${pct(num(r.embedded), num(r.chunks))})`,\n ])\n )\n );\n\n // ---- where it enters from ---------------------------------------------\n const paths = report.paths;\n\n const byRoot = new Map<string, number>();\n for (const r of paths) {\n const k = rootOf(String(r.path ?? \"\"));\n byRoot.set(k, (byRoot.get(k) ?? 0) + num(r.chunks));\n }\n const roots = [...byRoot.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);\n\n console.log();\n console.log(header(`Where it comes from`));\n console.log(dim(` A root you did not expect here is usually a symlink leading out of the vault.`));\n console.log();\n console.log(\n renderTable(\n [\"root\", \"chunks\", \"share\"],\n roots.map(([k, v]) => [k, v.toLocaleString(), pct(v, totalChunks)])\n )\n );\n\n // ---- heaviest single files -------------------------------------------\n const heaviest = [...paths]\n .sort((a, b) => num(b.chunks) - num(a.chunks))\n .slice(0, limit);\n\n console.log();\n console.log(header(`Heaviest single files`));\n console.log();\n console.log(\n renderTable(\n [\"chunks\", \"path\"],\n heaviest.map((r) => [num(r.chunks).toLocaleString(), tail(String(r.path ?? \"\"), 62)])\n )\n );\n\n // ---- churn ------------------------------------------------------------\n // The section that distinguishes a backlog from a treadmill. A day with a\n // large chunk count and a small embedded count means those chunks were\n // rewritten and their embeddings thrown away.\n const churn = report.churn;\n\n console.log();\n console.log(header(`Rewritten per day`));\n console.log(\n dim(` Many chunks with few embedded means they were re-chunked and their`)\n );\n console.log(dim(` embeddings discarded — work the embedder has to redo.`));\n console.log();\n console.log(\n renderTable(\n [\"day\", \"chunks touched\", \"of those embedded\"],\n churn.map((r) => [\n String(r.day),\n num(r.chunks).toLocaleString(),\n `${num(r.embedded).toLocaleString()} (${pct(num(r.embedded), num(r.chunks))})`,\n ])\n )\n );\n\n const missing = totalChunks - totalEmbedded;\n console.log();\n if (missing > 0) {\n console.log(` ${bold(missing.toLocaleString())} chunks still need embedding.`);\n } else {\n console.log(ok(` Everything indexed is embedded.`));\n }\n console.log();\n}\n\n/** Keep the informative end of a long path. */\nfunction tail(s: string, n: number): string {\n return s.length <= n ? s : \"…\" + s.slice(-(n - 1));\n}\n\n"],"mappings":"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"}
1
+ {"version":3,"file":"sources-LcGptLA-.mjs","names":[],"sources":["../src/cli/commands/memory/sources.ts"],"sourcesContent":["/**\n * `pai memory sources` — what the indexer is actually taking in.\n *\n * The index had grown to ~1.5M chunks against a vault of ~2,700 notes, and\n * nothing in the CLI could show why. `memory status` refuses to report at all\n * when the backend is not SQLite, and `daemon status` gives three totals with no\n * composition — so answering \"what is it indexing, and why is it re-indexing\"\n * meant hand-writing aggregate SQL against the container.\n *\n * The cause, when it was finally measured, was visible in one breakdown: the\n * vault indexer follows symlinks out of the vault into cloud-synced trees, and\n * those trees have mtimes rewritten by the sync client on content that has not\n * changed. Each rewrite re-chunks the file, and re-chunking assigns new chunk\n * ids, which discards the embeddings — so the embedder was re-doing work\n * indefinitely while never catching up.\n *\n * Hence the four sections below. Each one exists because it was needed:\n * composition — the vault dwarfing everything else is the first clue\n * roots — where content enters from, which is how symlink leakage shows\n * heaviest — single files contributing thousands of chunks (attachments)\n * churn — chunks rewritten per day, which is what distinguishes a\n * backlog that will finish from a treadmill that never will\n */\n\nimport { ok, warn, dim, bold, header, renderTable } from \"../../utils.js\";\nimport type { StorageBackend } from \"../../../storage/interface.js\";\n\nconst num = (v: unknown): number => Number(v ?? 0);\nconst pct = (part: number, whole: number): string =>\n whole === 0 ? \"—\" : `${Math.round((part / whole) * 100)}%`;\n\n/** Group the leading path segments, which is where content enters the index. */\nexport function rootOf(path: string, depth = 2): string {\n const parts = path.split(\"/\").filter(Boolean);\n if (parts.length <= depth) return path;\n return parts.slice(0, depth).join(\"/\") + \"/…\";\n}\n\nexport async function cmdMemorySources(\n backend: StorageBackend,\n opts: { limit?: number } = {}\n): Promise<void> {\n const limit = opts.limit ?? 8;\n\n const report = await backend.getMemorySourcesReport();\n const comp = report.composition;\n\n if (comp.length === 0) {\n console.log();\n console.log(warn(` Nothing indexed yet, or the backend is unreachable.`));\n console.log(dim(` Backend: ${backend.backendType}`));\n console.log();\n return;\n }\n\n const totalChunks = comp.reduce((s, r) => s + num(r.chunks), 0);\n const totalEmbedded = comp.reduce((s, r) => s + num(r.embedded), 0);\n\n console.log();\n console.log(header(`What the indexer has taken in`));\n console.log();\n console.log(\n ` ${bold(totalChunks.toLocaleString())} chunks ` +\n `${totalEmbedded.toLocaleString()} embedded (${pct(totalEmbedded, totalChunks)}) ` +\n dim(`backend: ${backend.backendType}`)\n );\n console.log();\n\n console.log(\n renderTable(\n [\"source / tier\", \"chunks\", \"share\", \"embedded\"],\n comp.map((r) => [\n `${r.source} / ${r.tier}`,\n num(r.chunks).toLocaleString(),\n pct(num(r.chunks), totalChunks),\n `${num(r.embedded).toLocaleString()} (${pct(num(r.embedded), num(r.chunks))})`,\n ])\n )\n );\n\n // ---- where it enters from ---------------------------------------------\n const paths = report.paths;\n\n const byRoot = new Map<string, number>();\n for (const r of paths) {\n const k = rootOf(String(r.path ?? \"\"));\n byRoot.set(k, (byRoot.get(k) ?? 0) + num(r.chunks));\n }\n const roots = [...byRoot.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit);\n\n console.log();\n console.log(header(`Where it comes from`));\n console.log(dim(` A root you did not expect here is usually a symlink leading out of the vault.`));\n console.log();\n console.log(\n renderTable(\n [\"root\", \"chunks\", \"share\"],\n roots.map(([k, v]) => [k, v.toLocaleString(), pct(v, totalChunks)])\n )\n );\n\n // ---- heaviest single files -------------------------------------------\n const heaviest = [...paths]\n .sort((a, b) => num(b.chunks) - num(a.chunks))\n .slice(0, limit);\n\n console.log();\n console.log(header(`Heaviest single files`));\n console.log();\n console.log(\n renderTable(\n [\"chunks\", \"path\"],\n heaviest.map((r) => [num(r.chunks).toLocaleString(), tail(String(r.path ?? \"\"), 62)])\n )\n );\n\n // ---- churn ------------------------------------------------------------\n // The section that distinguishes a backlog from a treadmill. A day with a\n // large chunk count and a small embedded count means those chunks were\n // rewritten and their embeddings thrown away.\n const churn = report.churn;\n\n console.log();\n console.log(header(`Rewritten per day`));\n console.log(\n dim(` Many chunks with few embedded means they were re-chunked and their`)\n );\n console.log(dim(` embeddings discarded — work the embedder has to redo.`));\n console.log();\n console.log(\n renderTable(\n [\"day\", \"chunks touched\", \"of those embedded\"],\n churn.map((r) => [\n String(r.day),\n num(r.chunks).toLocaleString(),\n `${num(r.embedded).toLocaleString()} (${pct(num(r.embedded), num(r.chunks))})`,\n ])\n )\n );\n\n const missing = totalChunks - totalEmbedded;\n console.log();\n if (missing > 0) {\n console.log(` ${bold(missing.toLocaleString())} chunks still need embedding.`);\n } else {\n console.log(ok(` Everything indexed is embedded.`));\n }\n console.log();\n}\n\n/** Keep the informative end of a long path. */\nfunction tail(s: string, n: number): string {\n return s.length <= n ? s : \"…\" + s.slice(-(n - 1));\n}\n\n"],"mappings":"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"}