@edspencer/paddock 0.0.1 → 0.56.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 (294) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +512 -0
  33. package/packages/server/dist/config.js +593 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/event-bus.d.ts +87 -0
  37. package/packages/server/dist/event-bus.js +48 -0
  38. package/packages/server/dist/git.d.ts +128 -0
  39. package/packages/server/dist/git.js +446 -0
  40. package/packages/server/dist/github-auth.d.ts +44 -0
  41. package/packages/server/dist/github-auth.js +169 -0
  42. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  43. package/packages/server/dist/herdctl-agent-config.js +331 -0
  44. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  45. package/packages/server/dist/herdctl-agent-names.js +228 -0
  46. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  47. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  48. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  49. package/packages/server/dist/herdctl-jobs.js +225 -0
  50. package/packages/server/dist/herdctl.d.ts +572 -0
  51. package/packages/server/dist/herdctl.js +1218 -0
  52. package/packages/server/dist/hook-config.d.ts +217 -0
  53. package/packages/server/dist/hook-config.js +224 -0
  54. package/packages/server/dist/http-bytes.d.ts +24 -0
  55. package/packages/server/dist/http-bytes.js +52 -0
  56. package/packages/server/dist/index.d.ts +1 -0
  57. package/packages/server/dist/index.js +14 -0
  58. package/packages/server/dist/instance-config.d.ts +135 -0
  59. package/packages/server/dist/instance-config.js +345 -0
  60. package/packages/server/dist/localcommand.d.ts +23 -0
  61. package/packages/server/dist/localcommand.js +140 -0
  62. package/packages/server/dist/management-auth.d.ts +99 -0
  63. package/packages/server/dist/management-auth.js +165 -0
  64. package/packages/server/dist/management-config.d.ts +164 -0
  65. package/packages/server/dist/management-config.js +286 -0
  66. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  67. package/packages/server/dist/management-mcp-server.js +95 -0
  68. package/packages/server/dist/management-metadata.d.ts +67 -0
  69. package/packages/server/dist/management-metadata.js +56 -0
  70. package/packages/server/dist/management-ops.d.ts +110 -0
  71. package/packages/server/dist/management-ops.js +449 -0
  72. package/packages/server/dist/management-policy.d.ts +172 -0
  73. package/packages/server/dist/management-policy.js +259 -0
  74. package/packages/server/dist/message-provenance.d.ts +112 -0
  75. package/packages/server/dist/message-provenance.js +260 -0
  76. package/packages/server/dist/models.d.ts +164 -0
  77. package/packages/server/dist/models.js +208 -0
  78. package/packages/server/dist/openapi.d.ts +66 -0
  79. package/packages/server/dist/openapi.js +183 -0
  80. package/packages/server/dist/parent-detach.d.ts +30 -0
  81. package/packages/server/dist/parent-detach.js +109 -0
  82. package/packages/server/dist/preload.d.ts +43 -0
  83. package/packages/server/dist/preload.js +55 -0
  84. package/packages/server/dist/project-files.d.ts +76 -0
  85. package/packages/server/dist/project-files.js +191 -0
  86. package/packages/server/dist/project-mime.d.ts +35 -0
  87. package/packages/server/dist/project-mime.js +55 -0
  88. package/packages/server/dist/project-paths.d.ts +88 -0
  89. package/packages/server/dist/project-paths.js +126 -0
  90. package/packages/server/dist/project-types.d.ts +316 -0
  91. package/packages/server/dist/project-types.js +48 -0
  92. package/packages/server/dist/projects.d.ts +202 -0
  93. package/packages/server/dist/projects.js +894 -0
  94. package/packages/server/dist/queued-message.d.ts +35 -0
  95. package/packages/server/dist/queued-message.js +116 -0
  96. package/packages/server/dist/read-state.d.ts +46 -0
  97. package/packages/server/dist/read-state.js +139 -0
  98. package/packages/server/dist/recovery-config.d.ts +111 -0
  99. package/packages/server/dist/recovery-config.js +105 -0
  100. package/packages/server/dist/recovery.d.ts +262 -0
  101. package/packages/server/dist/recovery.js +490 -0
  102. package/packages/server/dist/resume-drain.d.ts +122 -0
  103. package/packages/server/dist/resume-drain.js +158 -0
  104. package/packages/server/dist/route-context.d.ts +168 -0
  105. package/packages/server/dist/route-context.js +39 -0
  106. package/packages/server/dist/route-errors.d.ts +8 -0
  107. package/packages/server/dist/route-errors.js +13 -0
  108. package/packages/server/dist/routes/chats.d.ts +12 -0
  109. package/packages/server/dist/routes/chats.js +1406 -0
  110. package/packages/server/dist/routes/git.d.ts +13 -0
  111. package/packages/server/dist/routes/git.js +236 -0
  112. package/packages/server/dist/routes/mcp.d.ts +83 -0
  113. package/packages/server/dist/routes/mcp.js +250 -0
  114. package/packages/server/dist/routes/meta.d.ts +15 -0
  115. package/packages/server/dist/routes/meta.js +447 -0
  116. package/packages/server/dist/routes/projects.d.ts +25 -0
  117. package/packages/server/dist/routes/projects.js +736 -0
  118. package/packages/server/dist/routes/triggers.d.ts +21 -0
  119. package/packages/server/dist/routes/triggers.js +324 -0
  120. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  121. package/packages/server/dist/routes/workspace-mount.js +32 -0
  122. package/packages/server/dist/routes.d.ts +26 -0
  123. package/packages/server/dist/routes.js +29 -0
  124. package/packages/server/dist/run-provenance.d.ts +109 -0
  125. package/packages/server/dist/run-provenance.js +206 -0
  126. package/packages/server/dist/runs.d.ts +92 -0
  127. package/packages/server/dist/runs.js +72 -0
  128. package/packages/server/dist/schedule-config.d.ts +68 -0
  129. package/packages/server/dist/schedule-config.js +75 -0
  130. package/packages/server/dist/schedule-session.d.ts +34 -0
  131. package/packages/server/dist/schedule-session.js +120 -0
  132. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  133. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  134. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  135. package/packages/server/dist/self-mcp-read.js +97 -0
  136. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  137. package/packages/server/dist/self-mcp-triggers.js +144 -0
  138. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  139. package/packages/server/dist/self-mcp-types.js +10 -0
  140. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  141. package/packages/server/dist/self-mcp-util.js +157 -0
  142. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  143. package/packages/server/dist/self-mcp-write.js +215 -0
  144. package/packages/server/dist/self-mcp.d.ts +95 -0
  145. package/packages/server/dist/self-mcp.js +448 -0
  146. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  147. package/packages/server/dist/send-file-mcp.js +281 -0
  148. package/packages/server/dist/session-hub.d.ts +186 -0
  149. package/packages/server/dist/session-hub.js +291 -0
  150. package/packages/server/dist/spawn-capability.d.ts +99 -0
  151. package/packages/server/dist/spawn-capability.js +106 -0
  152. package/packages/server/dist/star.d.ts +26 -0
  153. package/packages/server/dist/star.js +94 -0
  154. package/packages/server/dist/start.d.ts +1 -0
  155. package/packages/server/dist/start.js +21 -0
  156. package/packages/server/dist/subagents.d.ts +337 -0
  157. package/packages/server/dist/subagents.js +626 -0
  158. package/packages/server/dist/sweep.d.ts +122 -0
  159. package/packages/server/dist/sweep.js +696 -0
  160. package/packages/server/dist/tooldetails.d.ts +33 -0
  161. package/packages/server/dist/tooldetails.js +466 -0
  162. package/packages/server/dist/transcribe.d.ts +69 -0
  163. package/packages/server/dist/transcribe.js +119 -0
  164. package/packages/server/dist/transcripts.d.ts +42 -0
  165. package/packages/server/dist/transcripts.js +164 -0
  166. package/packages/server/dist/trigger-config.d.ts +278 -0
  167. package/packages/server/dist/trigger-config.js +417 -0
  168. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  169. package/packages/server/dist/trigger-runtime.js +82 -0
  170. package/packages/server/dist/trigger-session.d.ts +34 -0
  171. package/packages/server/dist/trigger-session.js +122 -0
  172. package/packages/server/dist/triggers.d.ts +83 -0
  173. package/packages/server/dist/triggers.js +124 -0
  174. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  175. package/packages/server/dist/trusted-proxy.js +157 -0
  176. package/packages/server/dist/turn-notice.d.ts +154 -0
  177. package/packages/server/dist/turn-notice.js +271 -0
  178. package/packages/server/dist/unread.d.ts +36 -0
  179. package/packages/server/dist/unread.js +123 -0
  180. package/packages/server/dist/usage.d.ts +66 -0
  181. package/packages/server/dist/usage.js +243 -0
  182. package/packages/server/dist/wake-injection.d.ts +156 -0
  183. package/packages/server/dist/wake-injection.js +136 -0
  184. package/packages/server/dist/whisper-local.d.ts +9 -0
  185. package/packages/server/dist/whisper-local.js +122 -0
  186. package/packages/server/dist/ws-context.d.ts +158 -0
  187. package/packages/server/dist/ws-context.js +1 -0
  188. package/packages/server/dist/ws-protocol.d.ts +423 -0
  189. package/packages/server/dist/ws-protocol.js +251 -0
  190. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  191. package/packages/server/dist/ws-self-mcp.js +28 -0
  192. package/packages/server/dist/ws-triggers.d.ts +29 -0
  193. package/packages/server/dist/ws-triggers.js +261 -0
  194. package/packages/server/dist/ws-turn.d.ts +86 -0
  195. package/packages/server/dist/ws-turn.js +751 -0
  196. package/packages/server/dist/ws.d.ts +62 -0
  197. package/packages/server/dist/ws.js +1024 -0
  198. package/packages/server/package.json +6 -0
  199. package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +2 -0
  200. package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
  201. package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
  202. package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
  203. package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
  204. package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
  205. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
  206. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  207. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
  208. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  209. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
  210. package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
  211. package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
  212. package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
  213. package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
  214. package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
  215. package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
  216. package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
  217. package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
  218. package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
  219. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
  220. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
  221. package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
  222. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.js +2 -0
  223. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  224. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  225. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  226. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  227. package/packages/web/dist/assets/dagre-BM42HDAG-DdsLM6ql.js +5 -0
  228. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  229. package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
  230. package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
  231. package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
  232. package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
  233. package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
  234. package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
  235. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
  236. package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
  237. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
  238. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.js +107 -0
  239. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  240. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  241. package/packages/web/dist/assets/index-CGbZdrm8.js +71 -0
  242. package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
  243. package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.js +3 -0
  244. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  245. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  246. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js +71 -0
  247. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  248. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  249. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-mjU1fMc4.js +140 -0
  250. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  251. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.js +90 -0
  252. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  253. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  254. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  255. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  256. package/packages/web/dist/assets/linear-BxKd91ho.js +2 -0
  257. package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
  258. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.js +97 -0
  259. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  260. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  261. package/packages/web/dist/assets/pieDiagram-4H26LBE5-DNdqX7O5.js +31 -0
  262. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  263. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
  264. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  265. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.js +85 -0
  266. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  267. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  268. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-OAAhWcfK.js +41 -0
  269. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  270. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
  271. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  272. package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
  273. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
  274. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  275. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
  276. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  277. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
  278. package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
  279. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
  280. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  281. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.js +8 -0
  282. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  283. package/packages/web/dist/favicon.ico +0 -0
  284. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  285. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  286. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  287. package/packages/web/dist/icons/favicon-16.png +0 -0
  288. package/packages/web/dist/icons/favicon-32.png +0 -0
  289. package/packages/web/dist/icons/icon-192.png +0 -0
  290. package/packages/web/dist/icons/icon-512.png +0 -0
  291. package/packages/web/dist/icons/maskable-512.png +0 -0
  292. package/packages/web/dist/index.html +56 -0
  293. package/packages/web/dist/manifest.webmanifest +32 -0
  294. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Cumulative per-chat token usage — issue #152.
3
+ *
4
+ * `@herdctl/core`'s `extractSessionUsage` streams a transcript but keeps only the
5
+ * *last* assistant turn's `input + cache_read + cache_creation` — the current
6
+ * context-window fill level that drives the ContextRing / ContextMeter. It
7
+ * deliberately overwrites (`=`) each turn and never reads `output_tokens`, so it
8
+ * can't answer "how many tokens did this whole chat consume?".
9
+ *
10
+ * This module reads the same transcript in one pass and *accumulates* (`+=`)
11
+ * every assistant turn's input, output, cache-read and cache-creation tokens, so
12
+ * a chat's lifetime token cost (and a ballpark dollar figure at API rates) can be
13
+ * shown alongside the context-fill gauge. It lives in paddock — not upstream —
14
+ * because paddock already owns the `.chats/` layout and reads these transcripts
15
+ * directly (see {@link ./transcripts.ts} and {@link ./subagents.ts}); the core
16
+ * `SessionUsage` shape and its consumers are left untouched.
17
+ *
18
+ * Correctness notes:
19
+ * - Dedupe by `message.id` exactly like core: streaming writes the same message
20
+ * id more than once, and both the cumulative sums and the last-turn fill count
21
+ * each assistant message at most once (keep-first, matching core).
22
+ * - The last-turn `contextTokens` is tracked in the same pass so callers get the
23
+ * context-fill number without a second core call.
24
+ */
25
+ import { createReadStream } from "node:fs";
26
+ import { promises as fs } from "node:fs";
27
+ import { createInterface } from "node:readline";
28
+ import path from "node:path";
29
+ import { projectChatsDir } from "./transcripts.js";
30
+ /** sessionId is a path segment — keep it inside `.chats/`. */
31
+ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
32
+ const EMPTY = {
33
+ hasData: false,
34
+ turnCount: 0,
35
+ contextTokens: 0,
36
+ inputTotal: 0,
37
+ outputTotal: 0,
38
+ cacheReadTotal: 0,
39
+ cacheCreationTotal: 0,
40
+ byModel: {},
41
+ };
42
+ function num(v) {
43
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
44
+ }
45
+ /**
46
+ * Stream a transcript file and accumulate cumulative token usage. Deduplicates
47
+ * assistant messages by `message.id` (keep-first) so repeated stream frames for
48
+ * one message are counted once. Returns {@link EMPTY} for a missing/unreadable
49
+ * file (parity with core, which returns a zeroed summary).
50
+ */
51
+ export async function readSessionTokenUsageFile(file) {
52
+ const seenIds = new Set();
53
+ let hasData = false;
54
+ let contextTokens = 0;
55
+ let inputTotal = 0;
56
+ let outputTotal = 0;
57
+ let cacheReadTotal = 0;
58
+ let cacheCreationTotal = 0;
59
+ const byModel = {};
60
+ const stream = createReadStream(file, { encoding: "utf8" });
61
+ stream.on("error", () => undefined);
62
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
63
+ try {
64
+ for await (const line of rl) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed)
67
+ continue;
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(trimmed);
71
+ }
72
+ catch {
73
+ continue;
74
+ }
75
+ if (parsed.type !== "assistant")
76
+ continue;
77
+ const message = parsed.message;
78
+ if (!message)
79
+ continue;
80
+ // Deduplicate by message id (keep-first), exactly like core.
81
+ const id = typeof message.id === "string" ? message.id : undefined;
82
+ if (id) {
83
+ if (seenIds.has(id))
84
+ continue;
85
+ seenIds.add(id);
86
+ }
87
+ const usage = message.usage;
88
+ if (!usage)
89
+ continue;
90
+ hasData = true;
91
+ const input = num(usage.input_tokens);
92
+ const output = num(usage.output_tokens);
93
+ const cacheCreation = num(usage.cache_creation_input_tokens);
94
+ const cacheRead = num(usage.cache_read_input_tokens);
95
+ inputTotal += input;
96
+ outputTotal += output;
97
+ cacheCreationTotal += cacheCreation;
98
+ cacheReadTotal += cacheRead;
99
+ // Bucket the same tokens under the model that produced them, so cost is
100
+ // priced per-model rather than at one blended rate. Turns with no recorded
101
+ // model land under "" and are treated as unpriced downstream.
102
+ const model = typeof message.model === "string" ? message.model : "";
103
+ const bucket = (byModel[model] ??= {
104
+ inputTokens: 0,
105
+ outputTokens: 0,
106
+ cacheReadTokens: 0,
107
+ cacheCreationTokens: 0,
108
+ });
109
+ bucket.inputTokens += input;
110
+ bucket.outputTokens += output;
111
+ bucket.cacheReadTokens += cacheRead;
112
+ bucket.cacheCreationTokens += cacheCreation;
113
+ // Context fill of this turn — the last one to run wins (matches core).
114
+ contextTokens = input + cacheCreation + cacheRead;
115
+ }
116
+ }
117
+ catch {
118
+ return EMPTY;
119
+ }
120
+ finally {
121
+ rl.close();
122
+ stream.destroy();
123
+ }
124
+ return {
125
+ hasData,
126
+ turnCount: seenIds.size,
127
+ contextTokens,
128
+ inputTotal,
129
+ outputTotal,
130
+ cacheReadTotal,
131
+ cacheCreationTotal,
132
+ byModel,
133
+ };
134
+ }
135
+ /**
136
+ * mtime-keyed cache of cumulative usage, keyed by transcript path. A session's
137
+ * totals only change when its transcript grows (and its mtime bumps), so the
138
+ * chat-list build (which needs every session's usage for its rings) skips
139
+ * re-scanning unchanged transcripts. A `stat` is negligible next to a full-file
140
+ * walk, so we always derive the stamp here — one consistent mtime source across
141
+ * both the bulk and single-chat endpoints. One entry per file; a fresh mtime
142
+ * overwrites the stale one.
143
+ */
144
+ const cache = new Map();
145
+ /**
146
+ * Cumulative token usage for a chat, resolved from `<projectDir>/.chats/`.
147
+ * Memoized on the transcript's mtime. Returns {@link EMPTY} for an unsafe
148
+ * session id or a session with no transcript yet.
149
+ */
150
+ export async function readSessionTokenUsage(projectDir, sessionId) {
151
+ if (!SAFE_SEGMENT.test(sessionId))
152
+ return EMPTY;
153
+ const file = path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`);
154
+ const mtime = await fs
155
+ .stat(file)
156
+ .then((s) => s.mtimeMs)
157
+ .catch(() => 0);
158
+ if (!mtime)
159
+ return EMPTY;
160
+ const hit = cache.get(file);
161
+ if (hit && hit.mtime === mtime)
162
+ return hit.usage;
163
+ const usage = await readSessionTokenUsageFile(file);
164
+ cache.set(file, { mtime, usage });
165
+ return usage;
166
+ }
167
+ /**
168
+ * Per-message context-window fill, keyed by the transcript record `uuid` that the
169
+ * messages DTO carries (issue #451). For every `type:"assistant"` record we store
170
+ * `input + cache_read + cache_creation` — the context-window fill at that turn.
171
+ *
172
+ * A single assistant message is written across several JSONL records (one per
173
+ * content block: text, tool_use, …), each with its own `uuid` but the SAME
174
+ * message-level `usage`, so keying by record `uuid` resolves any of a message's
175
+ * turn ids to the identical fill. Callers forward-fill the value across the
176
+ * non-assistant records between turns (user prompts, tool results) to answer
177
+ * "how full was the context as of this point" for every message.
178
+ *
179
+ * This is a SEPARATE pass from {@link readSessionTokenUsageFile} on purpose: it
180
+ * only runs on the open-chat messages path, never the chat-list rings, so the
181
+ * list DTO stays lean. It rides its own mtime cache below.
182
+ */
183
+ export async function readContextSeriesFile(file) {
184
+ const series = new Map();
185
+ const stream = createReadStream(file, { encoding: "utf8" });
186
+ stream.on("error", () => undefined);
187
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
188
+ try {
189
+ for await (const line of rl) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed)
192
+ continue;
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(trimmed);
196
+ }
197
+ catch {
198
+ continue;
199
+ }
200
+ if (parsed.type !== "assistant")
201
+ continue;
202
+ const uuid = typeof parsed.uuid === "string" ? parsed.uuid : undefined;
203
+ const usage = parsed.message?.usage;
204
+ if (!uuid || !usage)
205
+ continue;
206
+ series.set(uuid, num(usage.input_tokens) +
207
+ num(usage.cache_creation_input_tokens) +
208
+ num(usage.cache_read_input_tokens));
209
+ }
210
+ }
211
+ catch {
212
+ return series;
213
+ }
214
+ finally {
215
+ rl.close();
216
+ stream.destroy();
217
+ }
218
+ return series;
219
+ }
220
+ /** mtime-keyed cache of the per-message context series (see {@link readContextSeriesFile}). */
221
+ const seriesCache = new Map();
222
+ /**
223
+ * Per-message context series for a chat, resolved from `<projectDir>/.chats/`.
224
+ * Memoized on the transcript's mtime (parity with {@link readSessionTokenUsage}).
225
+ * Returns an empty map for an unsafe session id or a session with no transcript.
226
+ */
227
+ export async function readContextSeries(projectDir, sessionId) {
228
+ if (!SAFE_SEGMENT.test(sessionId))
229
+ return new Map();
230
+ const file = path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`);
231
+ const mtime = await fs
232
+ .stat(file)
233
+ .then((s) => s.mtimeMs)
234
+ .catch(() => 0);
235
+ if (!mtime)
236
+ return new Map();
237
+ const hit = seriesCache.get(file);
238
+ if (hit && hit.mtime === mtime)
239
+ return hit.series;
240
+ const series = await readContextSeriesFile(file);
241
+ seriesCache.set(file, { mtime, series });
242
+ return series;
243
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Wake-time injected-MCP resolution (edspencer/herdctl#390, wired in
3
+ * @herdctl/core 5.22.1).
4
+ *
5
+ * ── The bug ───────────────────────────────────────────────────────────────────
6
+ * Paddock injects in-process MCP servers — `mcp__paddock_manage__*` (self-
7
+ * management, #214) and `mcp__paddock__*` (send_file, #113) — into keeper turns via
8
+ * `injectedMcpServers`. These are per-turn runtime values Paddock supplies only when
9
+ * IT drives a turn (the human socket path and every server-initiated `startAgentTurn`
10
+ * fire). herdctl's session WAKE path — a `ScheduleWakeup` / `/loop` / `CronCreate`
11
+ * re-fire of an idle/reaped session — drives the turn INSIDE herdctl and, before
12
+ * 5.22.1, re-spawned the `claude` subprocess with the `mcp__paddock*__*` tools still
13
+ * "allowed" (they're on `agent.allowed_tools`) but with NO in-process server behind
14
+ * them. Result: the injected tools vanish from the catalog for the whole autonomous
15
+ * stretch (observed multi-hour episodes; permanent after a host restart, since the
16
+ * durable wake set re-fires MCP-less). See `/tmp/mcp-flap-investigation.md`.
17
+ *
18
+ * ── The fix ───────────────────────────────────────────────────────────────────
19
+ * 5.22.1 added `FleetManager.setResolveInjectedMcpServers(resolve)` — a SYNCHRONOUS
20
+ * resolver herdctl calls on each wake fire and threads into `openChatSession` before
21
+ * the subprocess spawns. This module is Paddock's policy for that resolver.
22
+ *
23
+ * Because Paddock's live-turn builder is ASYNC (it awaits provenance + project state
24
+ * for depth/trigger gating) but the herdctl resolver is SYNC, we resolve from an
25
+ * in-memory cache of the last server set Paddock built for each session
26
+ * ({@link WakeInjectionResolver.remember}, called on every live turn). A wake fire
27
+ * ({@link WakeInjectionResolver.resolve}) replays the cached set synchronously. This
28
+ * closes the LIVE flap fully — including the common case (a human/spawn turn sets up
29
+ * a self-wake, so the cache is warm when that same session wakes).
30
+ *
31
+ * The cold-cache case (a durable wake re-firing after a server RESTART, before any
32
+ * live turn re-populates the cache) can't be served synchronously — the source data
33
+ * is async. On a miss we kick a background {@link WakeInjectionResolver.warm} that
34
+ * rebuilds + caches the set, so the NEXT wake for that session is covered; the FIRST
35
+ * post-restart wake still degrades to no-injection (the next human/Trigger turn — or
36
+ * the next wake — restores it). This is the single documented residual.
37
+ */
38
+ import type { InjectedMcpServerDef, SessionWakeEntry } from "@herdctl/core";
39
+ import type { TurnOrigin } from "./run-provenance.js";
40
+ /** The per-turn context that decides which injected servers a turn receives. */
41
+ export interface InjectedMcpBuildArgs {
42
+ /** Workspace key this turn runs in (`""` is the root). */
43
+ projectSlug: string;
44
+ /** Keeper cwd — resolves the send_file tool's relative `file_path`. */
45
+ workingDir: string;
46
+ /**
47
+ * The session being resumed, or `null` for a new chat. On a resume, the CHAT's
48
+ * OWN recorded depth (not the caller's describe-the-run value) governs self-MCP
49
+ * gating — a depth-1 child reporting back to its depth-0 root must gate on 0.
50
+ */
51
+ resume: string | null;
52
+ /** Origin stamped on any child this turn's self-MCP write tools spawn. */
53
+ origin: TurnOrigin;
54
+ /** The depth to gate on when there's no recorded provenance for {@link resume}. */
55
+ depth: number;
56
+ /** Effective spawn bound for the chat this turn runs in (resolved by the caller). */
57
+ maxSpawnDepth: number;
58
+ /** Late-bound accessor for the resolved session id (self-MCP tools attribute by it). */
59
+ currentSessionId: () => string | null;
60
+ }
61
+ /** The (mostly injected) collaborators {@link buildInjectedMcpServers} needs. */
62
+ export interface InjectedMcpBuildContext {
63
+ /** Instance self-MCP gates (resolved from `PaddockConfig`). */
64
+ cfg: {
65
+ selfMcpEnabled: boolean;
66
+ selfMcpWriteEnabled: boolean;
67
+ selfMcpProjectsEnabled: boolean;
68
+ hooksMcpEnabled: boolean;
69
+ };
70
+ /** Persist a file's bytes into the attachment store (send_file's snapshot). */
71
+ saveAttachment: (bytes: Buffer, filenameForExt: string) => Promise<string>;
72
+ /**
73
+ * Resolve a chat's recorded provenance for injection-depth on a resume. Optional
74
+ * (mirrors the optional `runProvenance` dep on the socket handler): absent ⇒ the
75
+ * caller's `depth` is used unchanged, exactly like the pre-extraction inline path.
76
+ */
77
+ getProvenance?: (sessionId: string) => Promise<{
78
+ depth: number;
79
+ } | undefined>;
80
+ /**
81
+ * Resolve a project's `hooksMcpEnabled` OVERRIDE (undefined when unset/unknown),
82
+ * for the T3 trigger-tool gate. Only consulted when write tools are on.
83
+ */
84
+ getProjectHooksMcp: (slug: string) => Promise<boolean | undefined>;
85
+ /**
86
+ * Build the self-management MCP server def. Provided by ws.ts because it closes
87
+ * over the hub / `startAgentTurn` spawn path; injected here so this module stays
88
+ * pure + unit-testable.
89
+ */
90
+ buildSelfMcp: (params: {
91
+ currentProjectSlug: string;
92
+ currentSessionId: () => string | null;
93
+ parentProvenance: {
94
+ origin: TurnOrigin;
95
+ depth: number;
96
+ };
97
+ includeWrite: boolean;
98
+ includeTriggers: boolean;
99
+ includeProjects: boolean;
100
+ }) => InjectedMcpServerDef;
101
+ }
102
+ /**
103
+ * Build the injected-MCP server set for one keeper turn — the SINGLE source of the
104
+ * per-turn injection policy, shared by the live `startAgentTurn` path and the wake
105
+ * rebuild so they can never drift. send_file is ALWAYS present (parity with the human
106
+ * path); the self-management MCP is appended iff the depth-gated
107
+ * {@link spawnedSelfMcpDecision} says so, and its T3 trigger tools iff writes are on
108
+ * AND the project's reused hooks-MCP gate is enabled. Semantics are identical to the
109
+ * previously-inline construction in `startAgentTurn`.
110
+ */
111
+ export declare function buildInjectedMcpServers(args: InjectedMcpBuildArgs, ctx: InjectedMcpBuildContext): Promise<Record<string, InjectedMcpServerDef>>;
112
+ /** Public surface of the wake-injection cache/resolver. */
113
+ export interface WakeInjectionResolver {
114
+ /**
115
+ * Record the server set Paddock built for a live turn, keyed by its session id, so
116
+ * a later wake of that session can replay it. Idempotent; a null/blank id is a
117
+ * no-op; the newest entry is most-recently-used for eviction.
118
+ */
119
+ remember(sessionId: string | null | undefined, servers: Record<string, InjectedMcpServerDef>): void;
120
+ /**
121
+ * The SYNCHRONOUS resolver registered with herdctl. On a cache hit it replays the
122
+ * remembered set (refreshing recency); on a miss it kicks a background
123
+ * {@link warm} (so the NEXT wake is covered) and returns `undefined` — no injection
124
+ * for THIS fire. NEVER throws.
125
+ */
126
+ resolve(entry: SessionWakeEntry): Record<string, InjectedMcpServerDef> | undefined;
127
+ /**
128
+ * Rebuild + cache the server set for a woken session (cold-cache warm after a
129
+ * restart). Deduplicates concurrent warms and is non-throwing.
130
+ */
131
+ warm(entry: SessionWakeEntry): Promise<Record<string, InjectedMcpServerDef> | undefined>;
132
+ /** True if a session's set is currently cached (test/introspection). */
133
+ has(sessionId: string): boolean;
134
+ /** Number of cached sessions (test/introspection). */
135
+ size(): number;
136
+ }
137
+ export interface WakeInjectionDeps {
138
+ /**
139
+ * Rebuild the injected servers for a woken session — resolves the project + its
140
+ * config and delegates to {@link buildInjectedMcpServers}. Returns `undefined` when
141
+ * the session should receive no injection (a non-keeper agent / unknown project).
142
+ * Should not
143
+ * reject (the cache catches defensively regardless).
144
+ */
145
+ rebuild: (entry: SessionWakeEntry) => Promise<Record<string, InjectedMcpServerDef> | undefined>;
146
+ /** Soft cap on cached sessions; oldest (LRU) evicted past it. Default 1024. */
147
+ limit?: number;
148
+ }
149
+ /** Default cap — generous vs. any realistic live-session count, bounds memory. */
150
+ export declare const DEFAULT_WAKE_INJECTION_LIMIT = 1024;
151
+ /**
152
+ * Create the wake-injection cache/resolver. Holds an LRU-ish map of the last server
153
+ * set built per session; the sync {@link WakeInjectionResolver.resolve} replays it on
154
+ * a wake, and a background {@link WakeInjectionResolver.warm} rebuilds a cold entry.
155
+ */
156
+ export declare function createWakeInjectionCache(deps: WakeInjectionDeps): WakeInjectionResolver;
@@ -0,0 +1,136 @@
1
+ import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
2
+ import { SELF_MCP_SERVER_KEY } from "./self-mcp.js";
3
+ import { spawnedSelfMcpDecision } from "./spawn-capability.js";
4
+ import { resolveHooksMcpEnabled } from "./hook-config.js";
5
+ /**
6
+ * Build the injected-MCP server set for one keeper turn — the SINGLE source of the
7
+ * per-turn injection policy, shared by the live `startAgentTurn` path and the wake
8
+ * rebuild so they can never drift. send_file is ALWAYS present (parity with the human
9
+ * path); the self-management MCP is appended iff the depth-gated
10
+ * {@link spawnedSelfMcpDecision} says so, and its T3 trigger tools iff writes are on
11
+ * AND the project's reused hooks-MCP gate is enabled. Semantics are identical to the
12
+ * previously-inline construction in `startAgentTurn`.
13
+ */
14
+ export async function buildInjectedMcpServers(args, ctx) {
15
+ const { projectSlug, workingDir, resume, origin, depth, maxSpawnDepth, currentSessionId } = args;
16
+ // Always-on send_file (parity with the human path).
17
+ const servers = {
18
+ [SEND_FILE_SERVER_KEY]: sendFileServerDef({
19
+ workingDirectory: workingDir,
20
+ saveAttachment: ctx.saveAttachment,
21
+ }),
22
+ };
23
+ // A resume runs in an EXISTING chat whose own recorded depth governs it; fall back
24
+ // to the caller's `depth` when the chat has no marker (or provenance isn't wired).
25
+ let injectionDepth = depth;
26
+ if (resume !== null && ctx.getProvenance) {
27
+ const rec = await ctx.getProvenance(resume).catch(() => undefined);
28
+ if (rec)
29
+ injectionDepth = rec.depth;
30
+ }
31
+ const selfMcp = spawnedSelfMcpDecision({
32
+ selfMcpEnabled: ctx.cfg.selfMcpEnabled,
33
+ selfMcpWriteEnabled: ctx.cfg.selfMcpWriteEnabled,
34
+ depth: injectionDepth,
35
+ maxSpawnDepth,
36
+ });
37
+ if (selfMcp.inject) {
38
+ // Trigger-management tools (T3) follow the TARGET project's reused hooks-MCP
39
+ // opt-in (override else instance default) — only meaningful with write tools.
40
+ let includeTriggers = false;
41
+ if (selfMcp.includeWrite) {
42
+ const override = await ctx.getProjectHooksMcp(projectSlug).catch(() => undefined);
43
+ includeTriggers = resolveHooksMcpEnabled(override, ctx.cfg.hooksMcpEnabled);
44
+ }
45
+ servers[SELF_MCP_SERVER_KEY] = ctx.buildSelfMcp({
46
+ currentProjectSlug: projectSlug,
47
+ currentSessionId,
48
+ parentProvenance: { origin, depth: injectionDepth },
49
+ includeWrite: selfMcp.includeWrite,
50
+ includeTriggers,
51
+ // The project tool (#467) is a purely instance-level gate — no per-project
52
+ // override — but still rides on the write block, so writes must be on.
53
+ includeProjects: selfMcp.includeWrite && ctx.cfg.selfMcpProjectsEnabled,
54
+ });
55
+ }
56
+ return servers;
57
+ }
58
+ /** Default cap — generous vs. any realistic live-session count, bounds memory. */
59
+ export const DEFAULT_WAKE_INJECTION_LIMIT = 1024;
60
+ /**
61
+ * Create the wake-injection cache/resolver. Holds an LRU-ish map of the last server
62
+ * set built per session; the sync {@link WakeInjectionResolver.resolve} replays it on
63
+ * a wake, and a background {@link WakeInjectionResolver.warm} rebuilds a cold entry.
64
+ */
65
+ export function createWakeInjectionCache(deps) {
66
+ const limit = deps.limit && deps.limit > 0 ? deps.limit : DEFAULT_WAKE_INJECTION_LIMIT;
67
+ const cache = new Map();
68
+ const inflight = new Map();
69
+ const remember = (sessionId, servers) => {
70
+ if (!sessionId)
71
+ return;
72
+ // delete-then-set moves the key to the newest position (Map preserves insertion
73
+ // order), so eviction below drops the genuinely least-recently-touched entry.
74
+ cache.delete(sessionId);
75
+ cache.set(sessionId, servers);
76
+ while (cache.size > limit) {
77
+ const oldest = cache.keys().next().value;
78
+ if (oldest === undefined)
79
+ break;
80
+ cache.delete(oldest);
81
+ }
82
+ };
83
+ const warm = (entry) => {
84
+ const id = entry?.sessionId;
85
+ if (!id)
86
+ return Promise.resolve(undefined);
87
+ const cached = cache.get(id);
88
+ if (cached)
89
+ return Promise.resolve(cached);
90
+ const existing = inflight.get(id);
91
+ if (existing)
92
+ return existing;
93
+ const p = (async () => {
94
+ try {
95
+ const servers = await deps.rebuild(entry);
96
+ if (servers)
97
+ remember(id, servers);
98
+ return servers;
99
+ }
100
+ catch {
101
+ return undefined; // a rebuild failure degrades to no-injection, never throws
102
+ }
103
+ finally {
104
+ inflight.delete(id);
105
+ }
106
+ })();
107
+ inflight.set(id, p);
108
+ return p;
109
+ };
110
+ const resolve = (entry) => {
111
+ try {
112
+ const id = entry?.sessionId;
113
+ if (!id)
114
+ return undefined;
115
+ const hit = cache.get(id);
116
+ if (hit) {
117
+ cache.delete(id);
118
+ cache.set(id, hit); // refresh recency
119
+ return hit;
120
+ }
121
+ // Cold cache (e.g. post-restart): warm for the NEXT fire, no injection now.
122
+ void warm(entry);
123
+ return undefined;
124
+ }
125
+ catch {
126
+ return undefined; // resolve is called sync by herdctl — must never throw
127
+ }
128
+ };
129
+ return {
130
+ remember,
131
+ resolve,
132
+ warm,
133
+ has: (sessionId) => cache.has(sessionId),
134
+ size: () => cache.size,
135
+ };
136
+ }
@@ -0,0 +1,9 @@
1
+ import type { TranscriptionConfig } from "./config.js";
2
+ import type { TranscriptionInput } from "./transcribe.js";
3
+ /**
4
+ * Transcribe an audio blob with whisper.cpp running on this box. Returns the raw
5
+ * transcript text (the caller trims it).
6
+ */
7
+ export declare function transcribeLocal(input: TranscriptionInput, cfg: TranscriptionConfig): Promise<string>;
8
+ /** Remove whisper.cpp's `[00:00:00.000 --> 00:00:02.000]` line prefixes. */
9
+ export declare function stripTimestamps(raw: string): string;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Local whisper.cpp transcription — the `local` backend for {@link makeTranscriber}.
3
+ *
4
+ * This module is imported LAZILY (see transcribe.ts) so its heavy, optional
5
+ * dependencies — `nodejs-whisper` (which builds/bundles whisper.cpp) and a
6
+ * system `ffmpeg` — are only required on instances that actually set
7
+ * `PADDOCK_WHISPER_MODE=local`. Remote-mode and dictation-off instances never
8
+ * touch it.
9
+ *
10
+ * Flow: the browser records WebM/Opus, which whisper.cpp can't read directly, so
11
+ * we first transcode to the 16 kHz mono PCM WAV whisper.cpp wants (via ffmpeg),
12
+ * then run whisper.cpp over it and return the cleaned transcript.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { TranscriptionError } from "./transcribe.js";
19
+ /**
20
+ * Transcribe an audio blob with whisper.cpp running on this box. Returns the raw
21
+ * transcript text (the caller trims it).
22
+ */
23
+ export async function transcribeLocal(input, cfg) {
24
+ const dir = await mkdtemp(path.join(os.tmpdir(), "paddock-whisper-"));
25
+ const srcPath = path.join(dir, safeName(input.filename));
26
+ const wavPath = path.join(dir, "audio.wav");
27
+ try {
28
+ await writeFile(srcPath, input.audio);
29
+ await toWav(srcPath, wavPath);
30
+ return await runWhisper(wavPath, cfg);
31
+ }
32
+ finally {
33
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
34
+ }
35
+ }
36
+ /** Transcode any input ffmpeg understands to 16 kHz mono PCM WAV. */
37
+ async function toWav(src, dest) {
38
+ await run("ffmpeg", ["-nostdin", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", dest], "ffmpeg (audio transcode)");
39
+ }
40
+ /**
41
+ * Run whisper.cpp via nodejs-whisper and return the transcript. nodejs-whisper
42
+ * writes a sibling `<wav>.txt`; we read that and strip whisper's `[hh:mm:ss.mmm
43
+ * --> …]` line prefixes so the composer gets clean prose.
44
+ */
45
+ async function runWhisper(wavPath, cfg) {
46
+ let nodewhisper;
47
+ try {
48
+ // Optional dependency — only present on instances configured for local
49
+ // whisper. Resolved dynamically so remote/off instances never need it.
50
+ // @ts-ignore — module may be absent at build time (declared optional).
51
+ ({ nodewhisper } = (await import("nodejs-whisper")));
52
+ }
53
+ catch {
54
+ throw new TranscriptionError("local whisper is not installed on this instance (missing nodejs-whisper). " +
55
+ "Install it, or set PADDOCK_WHISPER_MODE=remote.", 503);
56
+ }
57
+ const modelName = cfg.model || "base";
58
+ try {
59
+ await nodewhisper(wavPath, {
60
+ modelName,
61
+ autoDownloadModelName: modelName,
62
+ removeWavFileAfterTranscription: false,
63
+ // Only the plain-text output; we read the `.txt` and strip timestamps
64
+ // ourselves. (Avoid `splitOnWord`, which nodejs-whisper renders as a bare
65
+ // `-sow true`, making whisper-cli treat `true` as a stray input file.)
66
+ whisperOptions: {
67
+ outputInText: true,
68
+ outputInVtt: false,
69
+ outputInSrt: false,
70
+ outputInCsv: false,
71
+ translateToEnglish: false,
72
+ },
73
+ });
74
+ }
75
+ catch (err) {
76
+ const reason = err instanceof Error ? err.message : String(err);
77
+ throw new TranscriptionError(`whisper.cpp failed: ${truncate(reason, 300)}`, 502);
78
+ }
79
+ const txt = await readFile(`${wavPath}.txt`, "utf-8").catch(() => null);
80
+ if (txt === null) {
81
+ throw new TranscriptionError("whisper.cpp produced no transcript output", 502);
82
+ }
83
+ return stripTimestamps(txt);
84
+ }
85
+ /** Remove whisper.cpp's `[00:00:00.000 --> 00:00:02.000]` line prefixes. */
86
+ export function stripTimestamps(raw) {
87
+ return raw
88
+ .split("\n")
89
+ .map((line) => line.replace(/^\s*\[[0-9:.]+\s*-->\s*[0-9:.]+\]\s*/, ""))
90
+ .join("\n")
91
+ .replace(/\n{2,}/g, "\n")
92
+ .trim();
93
+ }
94
+ /** Spawn a process, rejecting with a clear TranscriptionError on failure. */
95
+ function run(cmd, args, label) {
96
+ return new Promise((resolve, reject) => {
97
+ const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
98
+ let stderr = "";
99
+ child.stderr.on("data", (d) => {
100
+ stderr += String(d);
101
+ });
102
+ child.on("error", (err) => {
103
+ const hint = err.code === "ENOENT"
104
+ ? ` — is ${cmd} installed and on PATH?`
105
+ : "";
106
+ reject(new TranscriptionError(`${label} could not start: ${err.message}${hint}`, 503));
107
+ });
108
+ child.on("close", (code) => {
109
+ if (code === 0)
110
+ resolve();
111
+ else
112
+ reject(new TranscriptionError(`${label} exited ${code}: ${truncate(stderr.trim(), 200)}`, 502));
113
+ });
114
+ });
115
+ }
116
+ function safeName(name) {
117
+ const base = path.basename(name || "audio").replace(/[^a-zA-Z0-9._-]/g, "_");
118
+ return base || "audio";
119
+ }
120
+ function truncate(s, n) {
121
+ return s.length > n ? `${s.slice(0, n)}…` : s;
122
+ }