@tekmidian/pai 0.63.0 → 0.65.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 (206) hide show
  1. package/ARCHITECTURE.md +3 -0
  2. package/README.md +38 -5
  3. package/dist/{aibroker-client-BYG-GtAg.mjs → aibroker-client-B8c42Lh8.mjs} +1 -1
  4. package/dist/{aibroker-client-BK1h--W_.mjs → aibroker-client-C5Fw7DNz.mjs} +10 -4
  5. package/dist/{aibroker-client-BK1h--W_.mjs.map → aibroker-client-C5Fw7DNz.mjs.map} +1 -1
  6. package/dist/{async-DNVWtQl2.mjs → async-CNn36gm4.mjs} +5 -4
  7. package/dist/async-CNn36gm4.mjs.map +1 -0
  8. package/dist/{auto-route-CF8WNaoy.mjs → auto-route-sLMU-NnM.mjs} +2 -2
  9. package/dist/{auto-route-CF8WNaoy.mjs.map → auto-route-sLMU-NnM.mjs.map} +1 -1
  10. package/dist/{chain-CUfE6WYK.mjs → chain-C8QO8gwj.mjs} +4 -4
  11. package/dist/{chain-CUfE6WYK.mjs.map → chain-C8QO8gwj.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.d.mts.map +1 -1
  16. package/dist/cli/program.mjs +20 -20
  17. package/dist/{clusters-Ci1mseh2.mjs → clusters-PgIUYT_v.mjs} +2 -2
  18. package/dist/{clusters-Ci1mseh2.mjs.map → clusters-PgIUYT_v.mjs.map} +1 -1
  19. package/dist/{config-CQCmH_gV.mjs → config-BbLFD7Uf.mjs} +3 -3
  20. package/dist/{config-CQCmH_gV.mjs.map → config-BbLFD7Uf.mjs.map} +1 -1
  21. package/dist/{config-DjXq_W42.mjs → config-YinjgXEJ.mjs} +1 -1
  22. package/dist/{context-handover-cache-DFNnlWv2.mjs → context-handover-cache-mGxq2-8f.mjs} +11 -29
  23. package/dist/context-handover-cache-mGxq2-8f.mjs.map +1 -0
  24. package/dist/daemon/index.mjs +16 -16
  25. package/dist/daemon-CX9JomIJ.mjs +19 -0
  26. package/dist/{daemon-Cex3dT9f.mjs → daemon-D2r1AQqE.mjs} +31 -30
  27. package/dist/{daemon-Cex3dT9f.mjs.map → daemon-D2r1AQqE.mjs.map} +1 -1
  28. package/dist/daemon-mcp/index.mjs +29 -20
  29. package/dist/daemon-mcp/index.mjs.map +1 -1
  30. package/dist/detector-C3Q7mxQU.mjs +3 -0
  31. package/dist/{detector-3g7XuU1r.mjs → detector-CIHEXKsV.mjs} +2 -2
  32. package/dist/{detector-3g7XuU1r.mjs.map → detector-CIHEXKsV.mjs.map} +1 -1
  33. package/dist/{embeddings-DfrYNIng.mjs → embeddings-BbNVXa_0.mjs} +1 -1
  34. package/dist/{embeddings-DfrYNIng.mjs.map → embeddings-BbNVXa_0.mjs.map} +1 -1
  35. package/dist/{embeddings-BNv3TQC5.mjs → embeddings-CDfM63uC.mjs} +1 -1
  36. package/dist/{factory-UO9_DFMN.mjs → factory-CaswxuJ0.mjs} +1 -1
  37. package/dist/{factory-BozZa81q.mjs → factory-y36qGegI.mjs} +13 -13
  38. package/dist/{factory-BozZa81q.mjs.map → factory-y36qGegI.mjs.map} +1 -1
  39. package/dist/{fallback-ktbR0aR7.mjs → fallback-CWDQYmJi.mjs} +1205 -1204
  40. package/dist/fallback-CWDQYmJi.mjs.map +1 -0
  41. package/dist/{federation-db-w8e-sKjp.mjs → federation-db-BTyoufBh.mjs} +2 -2
  42. package/dist/{federation-db-w8e-sKjp.mjs.map → federation-db-BTyoufBh.mjs.map} +1 -1
  43. package/dist/federation-db-HfIFc7FG.mjs +3 -0
  44. package/dist/{helpers-Dj-B314s.mjs → helpers-BRCJg0G3.mjs} +2 -208
  45. package/dist/helpers-BRCJg0G3.mjs.map +1 -0
  46. package/dist/hooks/block-sleep-poll.mjs +36 -11
  47. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  48. package/dist/hooks/capture-all-events.mjs +3 -17
  49. package/dist/hooks/capture-all-events.mjs.map +3 -3
  50. package/dist/hooks/capture-session-summary.mjs +3 -17
  51. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  52. package/dist/hooks/capture-tool-output.mjs +3 -17
  53. package/dist/hooks/capture-tool-output.mjs.map +3 -3
  54. package/dist/hooks/cleanup-session-files.mjs +3 -17
  55. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  56. package/dist/hooks/context-compression-hook.mjs +3 -17
  57. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  58. package/dist/hooks/initialize-session.mjs +3 -17
  59. package/dist/hooks/initialize-session.mjs.map +2 -2
  60. package/dist/hooks/inject-observations.mjs +3 -17
  61. package/dist/hooks/inject-observations.mjs.map +2 -2
  62. package/dist/hooks/load-core-context.mjs +4 -22
  63. package/dist/hooks/load-core-context.mjs.map +2 -2
  64. package/dist/hooks/load-project-context.mjs +3 -17
  65. package/dist/hooks/load-project-context.mjs.map +3 -3
  66. package/dist/hooks/mcp-deferred-gate.mjs +3 -17
  67. package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
  68. package/dist/hooks/observe.mjs +3 -17
  69. package/dist/hooks/observe.mjs.map +2 -2
  70. package/dist/hooks/security-validator.mjs +3 -17
  71. package/dist/hooks/security-validator.mjs.map +3 -3
  72. package/dist/hooks/stop-hook.mjs +3 -17
  73. package/dist/hooks/stop-hook.mjs.map +3 -3
  74. package/dist/hooks/sync-todo-to-md.mjs +3 -17
  75. package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
  76. package/dist/hooks/worker-guard.mjs.map +2 -2
  77. package/dist/hooks/worker-status-line.mjs +4 -4
  78. package/dist/hooks/worker-status-line.mjs.map +2 -2
  79. package/dist/index.d.mts +26 -8
  80. package/dist/index.d.mts.map +1 -1
  81. package/dist/index.mjs +5 -4
  82. package/dist/indexer-backend-Cnc7Tf5C.mjs +5 -0
  83. package/dist/{ipc-client-Dasm515c.mjs → ipc-client-D16Xw6Uo.mjs} +2 -2
  84. package/dist/{ipc-client-Dasm515c.mjs.map → ipc-client-D16Xw6Uo.mjs.map} +1 -1
  85. package/dist/{kg-entity-Cj8Q6YmU.mjs → kg-entity-COTUj1ZC.mjs} +1 -1
  86. package/dist/{kg-entity-Cj8Q6YmU.mjs.map → kg-entity-COTUj1ZC.mjs.map} +1 -1
  87. package/dist/{latent-ideas-BRy6HNwg.mjs → latent-ideas-ByvMjVcb.mjs} +4 -4
  88. package/dist/{latent-ideas-BRy6HNwg.mjs.map → latent-ideas-ByvMjVcb.mjs.map} +1 -1
  89. package/dist/{link-boost-BptbAPuv.mjs → link-boost-UiE-uooT.mjs} +1 -1
  90. package/dist/{link-boost-BptbAPuv.mjs.map → link-boost-UiE-uooT.mjs.map} +1 -1
  91. package/dist/{main-resolver-BIft_FVF.mjs → main-resolver-CDe7DCso.mjs} +6 -6
  92. package/dist/{main-resolver-BIft_FVF.mjs.map → main-resolver-CDe7DCso.mjs.map} +1 -1
  93. package/dist/main-resolver-DI-A7lSO.mjs +7 -0
  94. package/dist/merge-2gqRPFu2.mjs +3 -0
  95. package/dist/{merge-xoyfZQ4W.mjs → merge-DgU9OgZy.mjs} +1 -1
  96. package/dist/{merge-xoyfZQ4W.mjs.map → merge-DgU9OgZy.mjs.map} +1 -1
  97. package/dist/module-paths-DdRzbkUI.mjs +44 -0
  98. package/dist/module-paths-DdRzbkUI.mjs.map +1 -0
  99. package/dist/{neighborhood-D8uscnGy.mjs → neighborhood-CvRHlqdR.mjs} +2 -2
  100. package/dist/{neighborhood-D8uscnGy.mjs.map → neighborhood-CvRHlqdR.mjs.map} +1 -1
  101. package/dist/{note-context-CgffaUMh.mjs → note-context-BrbfUIoP.mjs} +1 -1
  102. package/dist/{note-context-CgffaUMh.mjs.map → note-context-BrbfUIoP.mjs.map} +1 -1
  103. package/dist/{pai-home-CeOIYltV.mjs → pai-home-Cm9rcJgX.mjs} +1 -1
  104. package/dist/{pai-home-CeOIYltV.mjs.map → pai-home-Cm9rcJgX.mjs.map} +1 -1
  105. package/dist/{planner-DAQMTMjP.mjs → planner-8-shIa8t.mjs} +7 -7
  106. package/dist/{planner-DAQMTMjP.mjs.map → planner-8-shIa8t.mjs.map} +1 -1
  107. package/dist/{postgres-CxD6_Dca.mjs → postgres-DYtZg7J7.mjs} +8 -15
  108. package/dist/{postgres-CxD6_Dca.mjs.map → postgres-DYtZg7J7.mjs.map} +1 -1
  109. package/dist/{program-De1GVVM4.mjs → program-DiktbiWa.mjs} +229 -249
  110. package/dist/program-DiktbiWa.mjs.map +1 -0
  111. package/dist/{query-feedback-xz5axTj3.mjs → query-feedback-BIaZTTFO.mjs} +2 -2
  112. package/dist/{query-feedback-xz5axTj3.mjs.map → query-feedback-BIaZTTFO.mjs.map} +1 -1
  113. package/dist/query-feedback-B_iigYj-.mjs +3 -0
  114. package/dist/{registry-db-Bkpbp7Gf.mjs → registry-db-C7voqML9.mjs} +2 -2
  115. package/dist/{registry-db-Bkpbp7Gf.mjs.map → registry-db-C7voqML9.mjs.map} +1 -1
  116. package/dist/registry-db-JHPhA8vF.mjs +3 -0
  117. package/dist/{registry-postgres-B07-hmn8.mjs → registry-postgres-DSrkxqfF.mjs} +2 -2
  118. package/dist/{registry-postgres-B07-hmn8.mjs.map → registry-postgres-DSrkxqfF.mjs.map} +1 -1
  119. package/dist/{registry-sqlite-CkGND_tm.mjs → registry-sqlite-B2436JgX.mjs} +2 -2
  120. package/dist/{registry-sqlite-CkGND_tm.mjs.map → registry-sqlite-B2436JgX.mjs.map} +1 -1
  121. package/dist/{reranker-BS5gqGeF.mjs → reranker-CFiEzHQu.mjs} +1 -1
  122. package/dist/{reranker-CRVawoEh.mjs → reranker-DKv80KO5.mjs} +1 -1
  123. package/dist/{reranker-CRVawoEh.mjs.map → reranker-DKv80KO5.mjs.map} +1 -1
  124. package/dist/router-S6C5BxzZ.mjs +3 -0
  125. package/dist/{router-DjFZIoDl.mjs → router-aqLMvNMg.mjs} +1 -1
  126. package/dist/{router-DjFZIoDl.mjs.map → router-aqLMvNMg.mjs.map} +1 -1
  127. package/dist/{run-CZVCCvXE.mjs → run-DUZRSnIT.mjs} +120 -42
  128. package/dist/run-DUZRSnIT.mjs.map +1 -0
  129. package/dist/{run-env-BE5xyCwy.mjs → run-env-BHWnXqle.mjs} +3 -3
  130. package/dist/{run-env-BE5xyCwy.mjs.map → run-env-BHWnXqle.mjs.map} +1 -1
  131. package/dist/{runtime-paths-BRwDeBEg.mjs → runtime-paths-CHTg3ywb.mjs} +1 -1
  132. package/dist/{runtime-paths-BRwDeBEg.mjs.map → runtime-paths-CHTg3ywb.mjs.map} +1 -1
  133. package/dist/{search-BaTbyWjK.mjs → search-BOSphCJ1.mjs} +2 -2
  134. package/dist/{search-BaTbyWjK.mjs.map → search-BOSphCJ1.mjs.map} +1 -1
  135. package/dist/search-Bf3Kub3F.mjs +3 -0
  136. package/dist/{server-BY6AedmC.mjs → server-DlL1QI2k.mjs} +4 -5
  137. package/dist/server-DlL1QI2k.mjs.map +1 -0
  138. package/dist/{session-keepalive-CWGzLBVU.mjs → session-keepalive-CZUwp5IQ.mjs} +7 -7
  139. package/dist/{session-keepalive-CWGzLBVU.mjs.map → session-keepalive-CZUwp5IQ.mjs.map} +1 -1
  140. package/dist/skills/Art/SKILL.md +1 -1
  141. package/dist/skills/Observability/SKILL.md +4 -4
  142. package/dist/skills/Research/SKILL.md +1 -1
  143. package/dist/{sources-BBe3AZ-W.mjs → sources-DX4ElmrE.mjs} +1 -1
  144. package/dist/{sources-BBe3AZ-W.mjs.map → sources-DX4ElmrE.mjs.map} +1 -1
  145. package/dist/{sqlite-CfKfuggn.mjs → sqlite-BrEu3avy.mjs} +8 -7
  146. package/dist/sqlite-BrEu3avy.mjs.map +1 -0
  147. package/dist/{state-CwlGAKgF.mjs → state-DW8zdweW.mjs} +1 -1
  148. package/dist/{state-n13uPK5B.mjs → state-HyjqTihC.mjs} +1 -1
  149. package/dist/{state-n13uPK5B.mjs.map → state-HyjqTihC.mjs.map} +1 -1
  150. package/dist/{stop-words-ClCwO8mZ.mjs → stop-words-BdQuaE9K.mjs} +1 -1
  151. package/dist/{stop-words-ClCwO8mZ.mjs.map → stop-words-BdQuaE9K.mjs.map} +1 -1
  152. package/dist/{themes-B2v7RNnA.mjs → themes-BgqahYVM.mjs} +3 -3
  153. package/dist/{themes-B2v7RNnA.mjs.map → themes-BgqahYVM.mjs.map} +1 -1
  154. package/dist/tools-BbqQIHPe.mjs +6 -0
  155. package/dist/{tools-DFdPbX-X.mjs → tools-gMGDIpJ9.mjs} +84 -68
  156. package/dist/tools-gMGDIpJ9.mjs.map +1 -0
  157. package/dist/{trace-BRflVyBi.mjs → trace-CmAB7iJZ.mjs} +1 -1
  158. package/dist/{trace-BRflVyBi.mjs.map → trace-CmAB7iJZ.mjs.map} +1 -1
  159. package/dist/{vault-indexer-mCFus_AM.mjs → vault-indexer-Ddq51X20.mjs} +4 -3
  160. package/dist/vault-indexer-Ddq51X20.mjs.map +1 -0
  161. package/dist/{wakeup-DO1O4udw.mjs → wakeup-CZw88uXf.mjs} +3 -3
  162. package/dist/{wakeup-DO1O4udw.mjs.map → wakeup-CZw88uXf.mjs.map} +1 -1
  163. package/dist/{work-queue-worker-BbrC3I_R.mjs → work-queue-worker-DCzfH0d1.mjs} +4 -4
  164. package/dist/{work-queue-worker-BbrC3I_R.mjs.map → work-queue-worker-DCzfH0d1.mjs.map} +1 -1
  165. package/dist/work-queue-worker-Taba_vcA.mjs +11 -0
  166. package/dist/{zettelkasten-BPxSOalU.mjs → zettelkasten-Dwj67e4p.mjs} +4 -4
  167. package/dist/{zettelkasten-BPxSOalU.mjs.map → zettelkasten-Dwj67e4p.mjs.map} +1 -1
  168. package/docker/docker-compose.yml +39 -0
  169. package/docs/commands/README.md +3 -3
  170. package/docs/commands/config.md +3 -3
  171. package/docs/commands/setup.md +9 -2
  172. package/docs/commands/worker.md +2 -2
  173. package/gemini-extension.json +1 -1
  174. package/package.json +3 -2
  175. package/pai-plugin.json +1 -0
  176. package/scripts/build-hooks.mjs +6 -6
  177. package/src/hooks/ts/lib/pai-paths-case.test.ts +12 -0
  178. package/src/hooks/ts/lib/pai-paths-import.test.ts +25 -0
  179. package/src/hooks/ts/lib/pai-paths.ts +5 -28
  180. package/src/hooks/ts/lib/sleep-poll-gate.test.ts +17 -1
  181. package/src/hooks/ts/lib/sleep-poll-gate.ts +21 -9
  182. package/src/hooks/ts/session-start/load-core-context.ts +2 -6
  183. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +24 -1
  184. package/dist/async-DNVWtQl2.mjs.map +0 -1
  185. package/dist/context-handover-cache-DFNnlWv2.mjs.map +0 -1
  186. package/dist/daemon-C8-uzOuV.mjs +0 -19
  187. package/dist/detector-DgDn56j-.mjs +0 -3
  188. package/dist/fallback-ktbR0aR7.mjs.map +0 -1
  189. package/dist/federation-db-CJDw_Pwq.mjs +0 -3
  190. package/dist/helpers-Dj-B314s.mjs.map +0 -1
  191. package/dist/indexer-backend-BN9rpp9O.mjs +0 -5
  192. package/dist/main-resolver-C9-poMdi.mjs +0 -7
  193. package/dist/merge-BncjJTfb.mjs +0 -3
  194. package/dist/program-De1GVVM4.mjs.map +0 -1
  195. package/dist/query-feedback-COYCnvHQ.mjs +0 -3
  196. package/dist/registry-db-DwXpf7yF.mjs +0 -3
  197. package/dist/router-C5hPk9YP.mjs +0 -3
  198. package/dist/run-CZVCCvXE.mjs.map +0 -1
  199. package/dist/search-BQHL5wDr.mjs +0 -3
  200. package/dist/server-BY6AedmC.mjs.map +0 -1
  201. package/dist/sqlite-CfKfuggn.mjs.map +0 -1
  202. package/dist/tools-B52IvaU6.mjs +0 -6
  203. package/dist/tools-DFdPbX-X.mjs.map +0 -1
  204. package/dist/vault-indexer-mCFus_AM.mjs.map +0 -1
  205. package/dist/work-queue-worker-z-AATZ2t.mjs +0 -11
  206. /package/dist/{indexer-backend-Cc-_NK3q.mjs → indexer-backend-isSLg6yE.mjs} +0 -0
@@ -3,212 +3,6 @@ import { homedir } from "node:os";
3
3
  import { basename, join, normalize } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
 
6
- //#region src/utils/hash.ts
7
- /**
8
- * Shared hashing utilities. Centralises all SHA-256 usage so every module
9
- * obtains digests through the same function rather than inlining createHash.
10
- */
11
- /**
12
- * Compute a SHA-256 hex digest of the given string.
13
- * Aliased as sha256File for compatibility with existing call-sites that use
14
- * that name to hash file contents.
15
- */
16
- function sha256(content) {
17
- return createHash("sha256").update(content).digest("hex");
18
- }
19
- /** Alias kept for backwards compatibility with memory/indexer call-sites. */
20
- const sha256File = sha256;
21
-
22
- //#endregion
23
- //#region src/memory/chunker.ts
24
- /**
25
- * Markdown text chunker for the PAI memory engine.
26
- *
27
- * Splits markdown files into overlapping text segments suitable for BM25
28
- * full-text indexing. Respects heading boundaries where possible, falling
29
- * back to paragraph and sentence splitting when sections are large.
30
- */
31
- const DEFAULT_MAX_TOKENS = 400;
32
- const DEFAULT_OVERLAP = 80;
33
- /**
34
- * Approximate token count using a words * 1.3 heuristic.
35
- * Matches the OpenClaw estimate approach.
36
- */
37
- function estimateTokens(text) {
38
- const wordCount = text.split(/\s+/).filter(Boolean).length;
39
- return Math.ceil(wordCount * 1.3);
40
- }
41
- /**
42
- * Split content into sections delimited by ## or ### headings.
43
- * Each section starts at its heading line (or at line 1 for a preamble).
44
- */
45
- function splitBySections(lines) {
46
- const sections = [];
47
- let current = [];
48
- for (const line of lines) {
49
- if (/^#{1,3}\s/.test(line.text) && current.length > 0) {
50
- const text = current.map((l) => l.text).join("\n");
51
- sections.push({
52
- lines: current,
53
- tokens: estimateTokens(text)
54
- });
55
- current = [];
56
- }
57
- current.push(line);
58
- }
59
- if (current.length > 0) {
60
- const text = current.map((l) => l.text).join("\n");
61
- sections.push({
62
- lines: current,
63
- tokens: estimateTokens(text)
64
- });
65
- }
66
- return sections;
67
- }
68
- /**
69
- * Split a LineBlock by double-newline paragraph boundaries.
70
- */
71
- function splitByParagraphs(block) {
72
- const paragraphs = [];
73
- let current = [];
74
- for (const line of block.lines) if (line.text.trim() === "" && current.length > 0) {
75
- const text = current.map((l) => l.text).join("\n");
76
- paragraphs.push({
77
- lines: [...current],
78
- tokens: estimateTokens(text)
79
- });
80
- current = [];
81
- } else current.push(line);
82
- if (current.length > 0) {
83
- const text = current.map((l) => l.text).join("\n");
84
- paragraphs.push({
85
- lines: current,
86
- tokens: estimateTokens(text)
87
- });
88
- }
89
- return paragraphs.length > 0 ? paragraphs : [block];
90
- }
91
- /**
92
- * Split a LineBlock by sentence boundaries (. ! ?) when even paragraphs are
93
- * too large. Works character-by-character within joined lines.
94
- */
95
- function splitBySentences(block, maxTokens) {
96
- const sentences = block.lines.map((l) => l.text).join(" ").split(/(?<=[.!?])\s+(?=[A-Z"'])/g);
97
- const result = [];
98
- let accText = "";
99
- const startLine = block.lines[0]?.lineNo ?? 1;
100
- const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;
101
- const totalLines = endLine - startLine + 1;
102
- const linesPerSentence = Math.max(1, Math.floor(totalLines / Math.max(1, sentences.length)));
103
- let sentenceIdx = 0;
104
- let approxLine = startLine;
105
- const flush = () => {
106
- if (!accText.trim()) return;
107
- const endApprox = Math.min(approxLine + linesPerSentence - 1, endLine);
108
- result.push({
109
- lines: [{
110
- text: accText.trim(),
111
- lineNo: approxLine
112
- }],
113
- tokens: estimateTokens(accText)
114
- });
115
- approxLine = endApprox + 1;
116
- accText = "";
117
- };
118
- for (const sentence of sentences) {
119
- sentenceIdx++;
120
- const candidateText = accText ? accText + " " + sentence : sentence;
121
- if (estimateTokens(candidateText) > maxTokens && accText) {
122
- flush();
123
- accText = sentence;
124
- } else accText = candidateText;
125
- }
126
- flush();
127
- return result.length > 0 ? result : [block];
128
- }
129
- /**
130
- * Extract the last `overlapTokens` worth of text from a list of previously
131
- * emitted chunks to prepend to the next chunk.
132
- */
133
- function buildOverlapPrefix(chunks, overlapTokens) {
134
- if (overlapTokens <= 0 || chunks.length === 0) return [];
135
- const lastChunk = chunks[chunks.length - 1];
136
- if (!lastChunk) return [];
137
- const lines = lastChunk.text.split("\n");
138
- const kept = [];
139
- let acc = 0;
140
- for (let i = lines.length - 1; i >= 0; i--) {
141
- const lineTokens = estimateTokens(lines[i] ?? "");
142
- acc += lineTokens;
143
- kept.unshift(lines[i] ?? "");
144
- if (acc >= overlapTokens) break;
145
- }
146
- const startLine = lastChunk.endLine - kept.length + 1;
147
- return kept.map((text, idx) => ({
148
- text,
149
- lineNo: Math.max(lastChunk.startLine, startLine + idx)
150
- }));
151
- }
152
- /**
153
- * Chunk a markdown file into overlapping segments for BM25 indexing.
154
- *
155
- * Strategy:
156
- * 1. Split by headings (##, ###) as natural boundaries.
157
- * 2. If a section exceeds maxTokens, split by paragraphs.
158
- * 3. If a paragraph still exceeds maxTokens, split by sentences.
159
- * 4. Apply overlap: each chunk includes the last `overlap` tokens from the
160
- * previous chunk.
161
- */
162
- /**
163
- * Strip `<private>...</private>` blocks from content before indexing.
164
- * Content within these tags is excluded from memory — never stored or searched.
165
- */
166
- function stripPrivateTags(content) {
167
- return content.replace(/<private>[\s\S]*?<\/private>/gi, "");
168
- }
169
- function chunkMarkdown(content, opts) {
170
- const maxTokens = opts?.maxTokens ?? DEFAULT_MAX_TOKENS;
171
- const overlapTokens = opts?.overlap ?? DEFAULT_OVERLAP;
172
- content = stripPrivateTags(content);
173
- if (!content.trim()) return [];
174
- const sections = splitBySections(content.split("\n").map((text, idx) => ({
175
- text,
176
- lineNo: idx + 1
177
- })));
178
- const finalBlocks = [];
179
- for (const section of sections) {
180
- if (section.tokens <= maxTokens) {
181
- finalBlocks.push(section);
182
- continue;
183
- }
184
- const paras = splitByParagraphs(section);
185
- for (const para of paras) {
186
- if (para.tokens <= maxTokens) {
187
- finalBlocks.push(para);
188
- continue;
189
- }
190
- const sentences = splitBySentences(para, maxTokens);
191
- finalBlocks.push(...sentences);
192
- }
193
- }
194
- const chunks = [];
195
- for (const block of finalBlocks) {
196
- if (block.lines.length === 0) continue;
197
- const text = [...buildOverlapPrefix(chunks, overlapTokens), ...block.lines].map((l) => l.text).join("\n").trim();
198
- if (!text) continue;
199
- const startLine = block.lines[0]?.lineNo ?? 1;
200
- const endLine = block.lines[block.lines.length - 1]?.lineNo ?? startLine;
201
- chunks.push({
202
- text,
203
- startLine,
204
- endLine,
205
- hash: sha256(text)
206
- });
207
- }
208
- return chunks;
209
- }
210
-
211
- //#endregion
212
6
  //#region src/memory/indexer/helpers.ts
213
7
  /**
214
8
  * Shared helpers for the PAI memory indexers.
@@ -425,5 +219,5 @@ function parseSessionTitleChunk(fileName) {
425
219
  const INDEX_YIELD_EVERY = 10;
426
220
 
427
221
  //#endregion
428
- export { parseSessionTitleChunk as a, yieldToEventLoop as c, sha256 as d, sha256File as f, isPathTooBroadForContentScan as i, chunkMarkdown as l, chunkId as n, walkContentFiles as o, detectTier as r, walkMdFiles as s, INDEX_YIELD_EVERY as t, estimateTokens as u };
429
- //# sourceMappingURL=helpers-Dj-B314s.mjs.map
222
+ export { parseSessionTitleChunk as a, yieldToEventLoop as c, isPathTooBroadForContentScan as i, chunkId as n, walkContentFiles as o, detectTier as r, walkMdFiles as s, INDEX_YIELD_EVERY as t };
223
+ //# sourceMappingURL=helpers-BRCJg0G3.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"helpers-BRCJg0G3.mjs","names":[],"sources":["../src/memory/indexer/helpers.ts"],"sourcesContent":["/**\n * Shared helpers for the PAI memory indexers.\n *\n * Contains utilities used by both the sync (SQLite) and async (StorageBackend)\n * indexer paths: hashing, chunk ID generation, directory walking, and path guards.\n */\n\nimport { readdirSync, existsSync } from \"node:fs\";\nimport { createHash } from \"node:crypto\";\nimport { sha256File } from \"../../utils/hash.js\";\nimport { join, normalize } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { basename } from \"node:path\";\n\n// ---------------------------------------------------------------------------\n// Tier detection\n// ---------------------------------------------------------------------------\n\n/**\n * Classify a relative file path into one of the four memory tiers.\n *\n * Rules (in priority order):\n * - MEMORY.md anywhere in memory/ → 'evergreen'\n * - YYYY-MM-DD.md in memory/ → 'daily'\n * - anything else in memory/ → 'topic'\n * - anything in Notes/ → 'session'\n */\nexport function detectTier(\n relativePath: string,\n): \"evergreen\" | \"daily\" | \"topic\" | \"session\" {\n // Normalise to forward slashes and strip leading ./\n const p = relativePath.replace(/\\\\/g, \"/\").replace(/^\\.\\//, \"\");\n\n // Notes directory → session tier\n if (p.startsWith(\"Notes/\") || p === \"Notes\") {\n return \"session\";\n }\n\n const fileName = basename(p);\n\n // MEMORY.md (case-sensitive match) → evergreen\n if (fileName === \"MEMORY.md\") {\n return \"evergreen\";\n }\n\n // YYYY-MM-DD.md → daily\n if (/^\\d{4}-\\d{2}-\\d{2}\\.md$/.test(fileName)) {\n return \"daily\";\n }\n\n // Default for memory/ files\n return \"topic\";\n}\n\n// ---------------------------------------------------------------------------\n// Hashing and chunk ID generation\n// ---------------------------------------------------------------------------\n\n// sha256File imported from ../../utils/hash.js\nexport { sha256File } from \"../../utils/hash.js\";\n\n/**\n * Generate a deterministic chunk ID from its coordinates.\n * Format: sha256(\"projectId:path:chunkIndex:startLine:endLine\")\n *\n * The chunkIndex (0-based position within the file) is included so that\n * chunks with approximated line numbers (e.g. from splitBySentences) never\n * produce colliding IDs even when multiple chunks share the same startLine/endLine.\n */\nexport function chunkId(\n projectId: number,\n path: string,\n chunkIndex: number,\n startLine: number,\n endLine: number,\n): string {\n return createHash(\"sha256\")\n .update(`${projectId}:${path}:${chunkIndex}:${startLine}:${endLine}`)\n .digest(\"hex\");\n}\n\n// ---------------------------------------------------------------------------\n// Event loop yield\n// ---------------------------------------------------------------------------\n\n/**\n * Yield to the Node.js event loop so that IPC server can process requests\n * during long index runs.\n *\n * Uses setTimeout(10ms) rather than setImmediate — the 10ms pause gives the\n * event loop enough time to accept and process incoming IPC connections\n * (socket data, new connections, etc.). Without this, synchronous ONNX\n * inference blocks IPC for the full duration of each embedding (~50-100ms\n * per chunk).\n */\nexport function yieldToEventLoop(): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, 10));\n}\n\n// ---------------------------------------------------------------------------\n// Directory skip sets\n// ---------------------------------------------------------------------------\n\n/**\n * Directories to ALWAYS skip, at any depth, during any directory walk.\n * These are build artifacts, dependency trees, and VCS internals that\n * should never be indexed regardless of where they appear in the tree.\n */\nexport const ALWAYS_SKIP_DIRS = new Set([\n // Version control\n \".git\",\n // Dependency directories (any language)\n \"node_modules\",\n \"vendor\",\n \"Pods\", // CocoaPods (iOS/macOS)\n // Build / compile output\n \"dist\",\n \"build\",\n \"out\",\n \"DerivedData\", // Xcode\n \".next\", // Next.js\n // Python virtual environments and caches\n \".venv\",\n \"venv\",\n \"__pycache__\",\n // General caches\n \".cache\",\n \".bun\",\n // Backup snapshots (Carbon Copy Cloner, Time Machine, etc.)\n \"snaps\",\n \".Trashes\",\n // Worker worktrees — full repo copies checked out per worker run\n \"worktrees\",\n]);\n\n/**\n * Directories to skip when doing a root-level content scan.\n * These are either already handled by dedicated scans or should never be indexed.\n */\nexport const ROOT_SCAN_SKIP_DIRS = new Set([\n \"memory\",\n \"Notes\",\n \".claude\",\n \".DS_Store\",\n // Everything in ALWAYS_SKIP_DIRS is also excluded at root level\n ...ALWAYS_SKIP_DIRS,\n]);\n\n/**\n * Additional directories to skip at the content-scan level (first level below root).\n * These are common macOS/Linux home-directory or repo noise directories that are\n * never meaningful as project content.\n */\nexport const CONTENT_SCAN_SKIP_DIRS = new Set([\n // macOS home directory standard folders\n \"Library\",\n \"Applications\",\n \"Music\",\n \"Movies\",\n \"Pictures\",\n \"Desktop\",\n \"Downloads\",\n \"Public\",\n // Common dev noise\n \"coverage\",\n // Everything in ALWAYS_SKIP_DIRS is also excluded at this level\n ...ALWAYS_SKIP_DIRS,\n]);\n\n// ---------------------------------------------------------------------------\n// Directory walkers\n// ---------------------------------------------------------------------------\n\n/**\n * Safety cap: maximum number of .md files collected per project scan.\n * Prevents runaway scans on huge root paths (e.g. home directory).\n * Projects with more files than this are scanned up to the cap only.\n */\nconst MAX_FILES_PER_PROJECT = 5_000;\n\n/**\n * Maximum recursion depth for directory walks.\n * Prevents deep traversal of large directory trees (e.g. development repos).\n * Depth 0 = the given directory itself (no recursion).\n * Value 6 allows: root → subdirs → sub-subdirs → ... up to 6 levels.\n * Sufficient for memory/, Notes/, and typical docs structures.\n */\nconst MAX_WALK_DEPTH = 6;\n\n/**\n * Recursively collect all .md files under a directory.\n * Returns absolute paths. Stops early if the accumulated count hits the cap\n * or if the recursion depth exceeds MAX_WALK_DEPTH.\n *\n * @param dir Directory to scan.\n * @param acc Shared accumulator array (mutated in place for early exit).\n * @param cap Maximum number of files to collect (across all recursive calls).\n * @param depth Current recursion depth (0 = the initial call).\n */\nexport function walkMdFiles(\n dir: string,\n acc?: string[],\n cap = MAX_FILES_PER_PROJECT,\n depth = 0,\n): string[] {\n const results = acc ?? [];\n if (!existsSync(dir)) return results;\n if (results.length >= cap) return results;\n if (depth > MAX_WALK_DEPTH) return results;\n\n try {\n for (const entry of readdirSync(dir, { withFileTypes: true })) {\n if (results.length >= cap) break;\n if (entry.isSymbolicLink()) continue;\n // Skip known junk directories at every recursion depth\n if (ALWAYS_SKIP_DIRS.has(entry.name)) continue;\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n walkMdFiles(full, results, cap, depth + 1);\n } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n results.push(full);\n }\n }\n } catch {\n // Unreadable directory — skip\n }\n return results;\n}\n\n/**\n * Recursively collect all .md files under rootPath, excluding directories\n * that are already covered by dedicated scans (memory/, Notes/) and\n * common noise directories (.git, node_modules, etc.).\n *\n * Returns absolute paths for files NOT already handled by the specific scanners.\n * Stops collecting once MAX_FILES_PER_PROJECT is reached.\n */\nexport function walkContentFiles(rootPath: string): string[] {\n if (!existsSync(rootPath)) return [];\n\n const results: string[] = [];\n try {\n for (const entry of readdirSync(rootPath, { withFileTypes: true })) {\n if (results.length >= MAX_FILES_PER_PROJECT) break;\n if (entry.isSymbolicLink()) continue;\n if (ROOT_SCAN_SKIP_DIRS.has(entry.name)) continue;\n if (CONTENT_SCAN_SKIP_DIRS.has(entry.name)) continue;\n\n const full = join(rootPath, entry.name);\n if (entry.isDirectory()) {\n walkMdFiles(full, results, MAX_FILES_PER_PROJECT);\n } else if (entry.isFile() && entry.name.endsWith(\".md\")) {\n // Skip root-level MEMORY.md — handled by the dedicated evergreen scan\n if (entry.name !== \"MEMORY.md\") {\n results.push(full);\n }\n }\n }\n } catch {\n // Unreadable directory — skip\n }\n return results;\n}\n\n// ---------------------------------------------------------------------------\n// Path safety guard\n// ---------------------------------------------------------------------------\n\n/** Paths that must never be indexed — system/temp dirs that can contain backup snapshots. */\nconst BLOCKED_ROOTS = new Set([\"/tmp\", \"/private/tmp\", \"/var\", \"/private/var\"]);\n\n/**\n * Returns true if rootPath should skip the recursive content scan.\n *\n * Skips content scanning for:\n * - The home directory itself or any ancestor (too broad — millions of files)\n * - Git repositories (code repos — index memory/ and Notes/ only, not all .md files)\n *\n * The content scan is still useful for Obsidian vaults, Notes folders, and\n * other doc-centric project trees where ALL markdown files are meaningful.\n *\n * The memory/, Notes/, and claude_notes_dir scans always run regardless.\n */\nexport function isPathTooBroadForContentScan(rootPath: string): boolean {\n const normalized = normalize(rootPath);\n\n // Block system/temp directories outright (CCC snapshots live here)\n if (BLOCKED_ROOTS.has(normalized)) return true;\n for (const blocked of BLOCKED_ROOTS) {\n if (normalized.startsWith(blocked + \"/\")) return true;\n }\n\n const home = homedir();\n\n // Skip the home directory itself or any ancestor of home\n if (home.startsWith(normalized) || normalized === \"/\") {\n return true;\n }\n\n // Skip home directory itself (depth 0)\n if (normalized.startsWith(home)) {\n const rel = normalized.slice(home.length).replace(/^\\//, \"\");\n const depth = rel ? rel.split(\"/\").length : 0;\n if (depth === 0) return true;\n }\n\n // Skip git repositories — content scan is only for doc-centric projects\n // (Obsidian vaults, knowledge bases). Code repos use memory/ and Notes/ only.\n if (existsSync(join(normalized, \".git\"))) {\n return true;\n }\n\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Session title parser\n// ---------------------------------------------------------------------------\n\nconst SESSION_TITLE_RE = /^(\\d{4})\\s*-\\s*(\\d{4}-\\d{2}-\\d{2})\\s*-\\s*(.+)\\.md$/;\n\n/**\n * Parse a session title from a Notes filename.\n * Format: \"NNNN - YYYY-MM-DD - Descriptive Title.md\"\n * Returns a synthetic chunk text like \"Session #0086 2026-02-23: Pai Daemon Background Service\"\n * or null if the filename doesn't match the expected pattern.\n */\nexport function parseSessionTitleChunk(fileName: string): string | null {\n const m = SESSION_TITLE_RE.exec(fileName);\n if (!m) return null;\n const [, num, date, title] = m;\n return `Session #${num} ${date}: ${title}`;\n}\n\n/** Number of files to process before yielding to the event loop inside indexProject. */\nexport const INDEX_YIELD_EVERY = 10;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AA2BA,SAAgB,WACd,cAC6C;CAE7C,MAAM,IAAI,aAAa,QAAQ,OAAO,IAAI,CAAC,QAAQ,SAAS,GAAG;AAG/D,KAAI,EAAE,WAAW,SAAS,IAAI,MAAM,QAClC,QAAO;CAGT,MAAM,WAAW,SAAS,EAAE;AAG5B,KAAI,aAAa,YACf,QAAO;AAIT,KAAI,0BAA0B,KAAK,SAAS,CAC1C,QAAO;AAIT,QAAO;;;;;;;;;;AAkBT,SAAgB,QACd,WACA,MACA,YACA,WACA,SACQ;AACR,QAAO,WAAW,SAAS,CACxB,OAAO,GAAG,UAAU,GAAG,KAAK,GAAG,WAAW,GAAG,UAAU,GAAG,UAAU,CACpE,OAAO,MAAM;;;;;;;;;;;;AAiBlB,SAAgB,mBAAkC;AAChD,QAAO,IAAI,SAAS,YAAY,WAAW,SAAS,GAAG,CAAC;;;;;;;AAY1D,MAAa,mBAAmB,IAAI,IAAI;CAEtC;CAEA;CACA;CACA;CAEA;CACA;CACA;CACA;CACA;CAEA;CACA;CACA;CAEA;CACA;CAEA;CACA;CAEA;CACD,CAAC;;;;;AAMF,MAAa,sBAAsB,IAAI,IAAI;CACzC;CACA;CACA;CACA;CAEA,GAAG;CACJ,CAAC;;;;;;AAOF,MAAa,yBAAyB,IAAI,IAAI;CAE5C;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CAEA;CAEA,GAAG;CACJ,CAAC;;;;;;AAWF,MAAM,wBAAwB;;;;;;;;AAS9B,MAAM,iBAAiB;;;;;;;;;;;AAYvB,SAAgB,YACd,KACA,KACA,MAAM,uBACN,QAAQ,GACE;CACV,MAAM,UAAU,OAAO,EAAE;AACzB,KAAI,CAAC,WAAW,IAAI,CAAE,QAAO;AAC7B,KAAI,QAAQ,UAAU,IAAK,QAAO;AAClC,KAAI,QAAQ,eAAgB,QAAO;AAEnC,KAAI;AACF,OAAK,MAAM,SAAS,YAAY,KAAK,EAAE,eAAe,MAAM,CAAC,EAAE;AAC7D,OAAI,QAAQ,UAAU,IAAK;AAC3B,OAAI,MAAM,gBAAgB,CAAE;AAE5B,OAAI,iBAAiB,IAAI,MAAM,KAAK,CAAE;GACtC,MAAM,OAAO,KAAK,KAAK,MAAM,KAAK;AAClC,OAAI,MAAM,aAAa,CACrB,aAAY,MAAM,SAAS,KAAK,QAAQ,EAAE;YACjC,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,MAAM,CACrD,SAAQ,KAAK,KAAK;;SAGhB;AAGR,QAAO;;;;;;;;;;AAWT,SAAgB,iBAAiB,UAA4B;AAC3D,KAAI,CAAC,WAAW,SAAS,CAAE,QAAO,EAAE;CAEpC,MAAM,UAAoB,EAAE;AAC5B,KAAI;AACF,OAAK,MAAM,SAAS,YAAY,UAAU,EAAE,eAAe,MAAM,CAAC,EAAE;AAClE,OAAI,QAAQ,UAAU,sBAAuB;AAC7C,OAAI,MAAM,gBAAgB,CAAE;AAC5B,OAAI,oBAAoB,IAAI,MAAM,KAAK,CAAE;AACzC,OAAI,uBAAuB,IAAI,MAAM,KAAK,CAAE;GAE5C,MAAM,OAAO,KAAK,UAAU,MAAM,KAAK;AACvC,OAAI,MAAM,aAAa,CACrB,aAAY,MAAM,SAAS,sBAAsB;YACxC,MAAM,QAAQ,IAAI,MAAM,KAAK,SAAS,MAAM,EAErD;QAAI,MAAM,SAAS,YACjB,SAAQ,KAAK,KAAK;;;SAIlB;AAGR,QAAO;;;AAQT,MAAM,gBAAgB,IAAI,IAAI;CAAC;CAAQ;CAAgB;CAAQ;CAAe,CAAC;;;;;;;;;;;;;AAc/E,SAAgB,6BAA6B,UAA2B;CACtE,MAAM,aAAa,UAAU,SAAS;AAGtC,KAAI,cAAc,IAAI,WAAW,CAAE,QAAO;AAC1C,MAAK,MAAM,WAAW,cACpB,KAAI,WAAW,WAAW,UAAU,IAAI,CAAE,QAAO;CAGnD,MAAM,OAAO,SAAS;AAGtB,KAAI,KAAK,WAAW,WAAW,IAAI,eAAe,IAChD,QAAO;AAIT,KAAI,WAAW,WAAW,KAAK,EAAE;EAC/B,MAAM,MAAM,WAAW,MAAM,KAAK,OAAO,CAAC,QAAQ,OAAO,GAAG;AAE5D,OADc,MAAM,IAAI,MAAM,IAAI,CAAC,SAAS,OAC9B,EAAG,QAAO;;AAK1B,KAAI,WAAW,KAAK,YAAY,OAAO,CAAC,CACtC,QAAO;AAGT,QAAO;;AAOT,MAAM,mBAAmB;;;;;;;AAQzB,SAAgB,uBAAuB,UAAiC;CACtE,MAAM,IAAI,iBAAiB,KAAK,SAAS;AACzC,KAAI,CAAC,EAAG,QAAO;CACf,MAAM,GAAG,KAAK,MAAM,SAAS;AAC7B,QAAO,YAAY,IAAI,GAAG,KAAK,IAAI;;;AAIrC,MAAa,oBAAoB"}
@@ -7518,32 +7518,57 @@ function sleepUnitMultiplier(unit) {
7518
7518
  return unit === "m" ? 60 : unit === "h" ? 3600 : unit === "d" ? 86400 : 1;
7519
7519
  }
7520
7520
  var SLEEP_SEGMENT_RE = /(?:^|[;&|\n(){]|\bdo\b|\bthen\b)\s*sleep\s+([0-9]+(?:\.[0-9]+)?)([smhd]?)(?=\s*(?:$|[;&|)\n]))/g;
7521
- function longestSleepSecs(command) {
7522
- let longest = null;
7523
- for (const m of command.matchAll(SLEEP_SEGMENT_RE)) {
7521
+ var LOOP_RE = /\b(for|while|until)\b([^;\n]*)[;\n]\s*do\b([\s\S]*?)\bdone\b/g;
7522
+ function sleepSum(text) {
7523
+ let sum = 0;
7524
+ for (const m of text.matchAll(SLEEP_SEGMENT_RE)) {
7524
7525
  const secs = Number(m[1]) * sleepUnitMultiplier(m[2]);
7525
- if (!Number.isFinite(secs) || secs < SLEEP_FLOOR_SECS) continue;
7526
- if (longest === null || secs > longest) longest = secs;
7526
+ if (Number.isFinite(secs)) sum += secs;
7527
7527
  }
7528
- return longest;
7528
+ return sum;
7529
+ }
7530
+ function forIterations(header) {
7531
+ const words = header.replace(/^\s*\w+\s+in\s+/, "").trim();
7532
+ if (words === header.trim() || /[$`*?]/.test(words)) return null;
7533
+ const range = /^\{(-?\d+)\.\.(-?\d+)\}$/.exec(words);
7534
+ if (range) return Math.abs(Number(range[2]) - Number(range[1])) + 1;
7535
+ return words ? words.split(/\s+/).length : 0;
7536
+ }
7537
+ function totalSleepSecs(command) {
7538
+ let total = sleepSum(command);
7539
+ for (const m of command.matchAll(LOOP_RE)) {
7540
+ const body = sleepSum(m[3]);
7541
+ if (body === 0) continue;
7542
+ if (m[1] === "for") {
7543
+ const n = forIterations(m[2]);
7544
+ total += n === null ? Math.max(SLEEP_FLOOR_SECS, body) : body * (n - 1);
7545
+ } else if (!/\bkill\s+-0\b/.test(m[2])) {
7546
+ total += SLEEP_FLOOR_SECS;
7547
+ }
7548
+ }
7549
+ return total;
7529
7550
  }
7530
7551
 
7531
7552
  // src/hooks/ts/lib/sleep-poll-gate.ts
7532
7553
  function denyReason(secs, isWorker) {
7533
7554
  const lead = `sleep ${secs}s blocked: do not sleep-poll. `;
7534
7555
  if (isWorker) {
7535
- return lead + "In a worker, run the long command in the FOREGROUND with a Bash timeout up to 600000 ms. If it can run longer, start it in the background and wait with a foreground loop that ends when the job ends: while kill -0 <pid> 2>/dev/null; do sleep 15; done";
7556
+ return lead + "In a worker, run the long command in the FOREGROUND with a Bash timeout up to 600000 ms and split longer jobs into steps; run_in_background is denied here.";
7536
7557
  }
7537
7558
  return lead + "Run the long command itself with run_in_background: true (the harness notifies when it exits), or wait on a specific condition with a bounded loop under 60s per step.";
7538
7559
  }
7560
+ var BACKGROUND_WORKER_REASON = "run_in_background is blocked in a worker: ending your turn ends this process and kills the job, there is no completion notification. Run the command in the FOREGROUND with a Bash timeout up to 600000 ms, and split a longer job into steps that each fit in that.";
7539
7561
  function decideSleepPollGate(input, env) {
7540
7562
  if (input.tool_name && input.tool_name !== "Bash") return { decision: "allow" };
7541
- if (input.tool_input?.run_in_background === true) return { decision: "allow" };
7563
+ const isWorker = env.PAI_WORKER === "1";
7564
+ if (input.tool_input?.run_in_background === true) {
7565
+ return isWorker ? { decision: "deny", reason: BACKGROUND_WORKER_REASON } : { decision: "allow" };
7566
+ }
7542
7567
  const command = input.tool_input?.command;
7543
7568
  if (typeof command !== "string") return { decision: "allow" };
7544
- const secs = longestSleepSecs(command);
7545
- if (secs === null) return { decision: "allow" };
7546
- return { decision: "deny", reason: denyReason(secs, env.PAI_WORKER === "1") };
7569
+ const secs = totalSleepSecs(command);
7570
+ if (secs < SLEEP_FLOOR_SECS) return { decision: "allow" };
7571
+ return { decision: "deny", reason: denyReason(secs, isWorker) };
7547
7572
  }
7548
7573
  function renderDecision(d) {
7549
7574
  if (d.decision === "allow") {