@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,33 @@
1
+ import { type EnrichedMessage, type EditDiff, type ReadInfo, type BashDetails, type SearchInfo, type TaskUpdateInfo, type TaskCreateInfo } from "./subagents.js";
2
+ /** The structured fields we derive for one paired tool_use, keyed for the join. */
3
+ interface ToolDetail {
4
+ toolName: string;
5
+ editDiff?: EditDiff;
6
+ readInfo?: ReadInfo;
7
+ bashDetails?: BashDetails;
8
+ searchInfo?: SearchInfo;
9
+ taskUpdate?: TaskUpdateInfo;
10
+ taskCreate?: TaskCreateInfo;
11
+ }
12
+ /** Recover per-tool details (file-ordered, paired-only) from a session transcript. */
13
+ export declare function readToolDetails(projectDir: string, sessionId: string): Promise<ToolDetail[]>;
14
+ /**
15
+ * Attach recovered per-tool detail to each detail-bearing tool message. The
16
+ * recovered details and the parsed messages are joined **per tool name, in file
17
+ * order** — the same paired-only/file-ordered invariant `enrichWithEdits`/
18
+ * `attachSubagentFields` rely on (which filter both sides to their tool set),
19
+ * bucketed by name so the defensive `toolName` match is intrinsic AND a tool herdctl
20
+ * happens to drop from its parsed stream (e.g. an interrupted, empty-output Bash)
21
+ * only misaligns its own family, never the whole tail. Sub-agent fields already
22
+ * attached by `enrichWithSubagents` are preserved (Task/Agent aren't in
23
+ * `DETAIL_TOOL_NAMES`). Detail-free sessions pass through unchanged.
24
+ */
25
+ export declare function attachToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
26
+ /**
27
+ * The single history-enrichment orchestrator (issue #237). Composes the three
28
+ * server-side passes so the routes call one function: sub-agent recovery (#37) →
29
+ * generalized per-tool detail (#237, subsuming #232's edit diff) → background /
30
+ * task-notification folding (#230) → local-command stdout recovery (#158).
31
+ */
32
+ export declare function enrichWithToolDetails(projectDir: string, sessionId: string, messages: EnrichedMessage[]): Promise<EnrichedMessage[]>;
33
+ export {};
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Unified tool-call detail enrichment — issue #237.
3
+ *
4
+ * herdctl's parsed `ChatToolCall` surfaces only `{toolName, inputSummary, output,
5
+ * isError, durationMs}` and discards two rich sources present on ~100% of tool
6
+ * calls in the raw Claude Code transcript:
7
+ *
8
+ * 1. the tool's full `input` (raw args), and
9
+ * 2. a structured `toolUseResult` sidecar written as a **top-level field** on the
10
+ * `tool_result` line (Edit `structuredPatch`, Read `file`, Bash stdout/stderr,
11
+ * Grep/Glob counts, Task* transitions, …).
12
+ *
13
+ * We'd recovered raw transcript data three times in one-off modules — `subagents.ts`
14
+ * (#37: Task input), `background.ts` (#230: task-notification correlation),
15
+ * `editdiff.ts` (#232: Edit diff). This module **generalizes that recovery into one
16
+ * shared pass**: a single mtime-cached raw-JSONL stream recovers `{input,
17
+ * toolUseResult}` for every paired tool_use and **positionally joins** it onto
18
+ * herdctl's parsed tool messages (same paired-only, file-ordered invariant
19
+ * `attachSubagentFields`/`enrichWithEdits` rely on; defensive `toolName` check).
20
+ *
21
+ * Recovery is generic; per-tool structured fields are derived here and rendered by
22
+ * a small set of gated branches in `ToolBlock`. No herdctl change. History-hydrated
23
+ * only (the live WS frame carries none of this) — enrichment applies on reload,
24
+ * consistent with #230/#232, and any tool without the sidecar degrades to the
25
+ * generic block.
26
+ *
27
+ * `enrichWithToolDetails` is the single orchestrator the history routes call: it
28
+ * composes `enrichWithSubagents` (sub-agent sidecars — a separate concern) → this
29
+ * pass → `enrichWithBackground` (task-notification folding).
30
+ */
31
+ import { createReadStream } from "node:fs";
32
+ import { createInterface } from "node:readline";
33
+ import path from "node:path";
34
+ import { projectChatsDir } from "./transcripts.js";
35
+ import { fileKind } from "./projects.js";
36
+ import { enrichWithSubagents } from "./subagents.js";
37
+ import { enrichWithBackground } from "./background.js";
38
+ import { enrichWithLocalCommands } from "./localcommand.js";
39
+ import { SAFE_SEGMENT, statMtimeMs, mtimeCacheGet, mtimeCacheSet, } from "./subagents.js";
40
+ /** Tool names we derive structured detail for. Others still consume a join slot
41
+ * (so positional alignment holds) but carry no extra fields. */
42
+ const EDIT_TOOL_NAMES = new Set(["Edit", "MultiEdit", "Write"]);
43
+ const DETAIL_TOOL_NAMES = new Set([
44
+ ...EDIT_TOOL_NAMES,
45
+ "Read",
46
+ "Bash",
47
+ "Grep",
48
+ "Glob",
49
+ "TaskUpdate",
50
+ "TaskCreate",
51
+ ]);
52
+ /** Cap rendered diff lines per edit tool call (stats stay exact; `truncated` flags it). */
53
+ const MAX_DIFF_LINES = 400;
54
+ const detailsCache = new Map();
55
+ // --- small coercers -------------------------------------------------------
56
+ /** Coerce to a trimmed non-empty string, else undefined. */
57
+ function str(v) {
58
+ if (typeof v !== "string")
59
+ return undefined;
60
+ const t = v;
61
+ return t.length ? t : undefined;
62
+ }
63
+ /** Coerce a string|number id to a string, else undefined. */
64
+ function id(v) {
65
+ if (typeof v === "string" && v.length)
66
+ return v;
67
+ if (typeof v === "number")
68
+ return String(v);
69
+ return undefined;
70
+ }
71
+ /** Coerce to a finite number, else undefined. */
72
+ function numOpt(v) {
73
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
74
+ }
75
+ /** Coerce to a finite number, else 0. */
76
+ function num(v) {
77
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
78
+ }
79
+ const asRec = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
80
+ // --- per-tool derivation --------------------------------------------------
81
+ /**
82
+ * Build the structured diff from `toolUseResult.structuredPatch` — the git-style
83
+ * hunks Claude Code already computed, with real file line numbers. Deletions carry
84
+ * an `oldLine`, additions a `newLine`, context both. Stats count all lines; a
85
+ * global render budget caps line count (`truncated`).
86
+ */
87
+ function editDiffFromResult(toolName, input, result) {
88
+ const patch = result?.structuredPatch;
89
+ if (!Array.isArray(patch))
90
+ return undefined;
91
+ const filePath = str(input.file_path) ?? str(result?.filePath);
92
+ const kind = toolName === "Write" ? "write" : toolName === "MultiEdit" ? "multiedit" : "edit";
93
+ let additions = 0;
94
+ let deletions = 0;
95
+ let budget = MAX_DIFF_LINES;
96
+ let truncated = false;
97
+ const hunks = [];
98
+ for (const rawHunk of patch) {
99
+ const h = asRec(rawHunk);
100
+ if (!h)
101
+ continue;
102
+ const oldStart = num(h.oldStart);
103
+ const newStart = num(h.newStart);
104
+ let oldNo = oldStart;
105
+ let newNo = newStart;
106
+ const lines = [];
107
+ const rawLines = Array.isArray(h.lines) ? h.lines : [];
108
+ for (const ln of rawLines) {
109
+ if (typeof ln !== "string")
110
+ continue;
111
+ const marker = ln[0];
112
+ const text = ln.slice(1);
113
+ if (marker === "+") {
114
+ additions++;
115
+ if (budget > 0) {
116
+ lines.push({ t: "+", text, newLine: newNo });
117
+ budget--;
118
+ }
119
+ else
120
+ truncated = true;
121
+ newNo++;
122
+ }
123
+ else if (marker === "-") {
124
+ deletions++;
125
+ if (budget > 0) {
126
+ lines.push({ t: "-", text, oldLine: oldNo });
127
+ budget--;
128
+ }
129
+ else
130
+ truncated = true;
131
+ oldNo++;
132
+ }
133
+ else if (marker === "\\") {
134
+ // "" — a jsdiff meta line, not real content.
135
+ continue;
136
+ }
137
+ else {
138
+ if (budget > 0) {
139
+ lines.push({ t: " ", text, oldLine: oldNo, newLine: newNo });
140
+ budget--;
141
+ }
142
+ else
143
+ truncated = true;
144
+ oldNo++;
145
+ newNo++;
146
+ }
147
+ }
148
+ hunks.push({
149
+ oldStart,
150
+ oldLines: num(h.oldLines),
151
+ newStart,
152
+ newLines: num(h.newLines),
153
+ lines,
154
+ });
155
+ }
156
+ return {
157
+ filePath,
158
+ kind,
159
+ additions,
160
+ deletions,
161
+ hunks,
162
+ truncated: truncated || undefined,
163
+ userModified: result?.userModified === true || undefined,
164
+ };
165
+ }
166
+ /** File + line-range for a Read. Derivable from `input.file_path` alone (basename),
167
+ * enriched with the range when the `toolUseResult.file` sidecar is present. */
168
+ function readInfoFromResult(input, result) {
169
+ const file = asRec(result?.file);
170
+ const filePath = str(input.file_path) ?? str(file?.filePath);
171
+ if (!filePath && !file)
172
+ return undefined;
173
+ return {
174
+ filePath,
175
+ basename: filePath ? (filePath.split("/").pop() ?? filePath) : undefined,
176
+ startLine: numOpt(file?.startLine),
177
+ numLines: numOpt(file?.numLines),
178
+ totalLines: numOpt(file?.totalLines),
179
+ // Image detection is extension-based, so it's transcript-only (cache-safe).
180
+ // The servable `projectRelPath` depends on the project dir and is filled in at
181
+ // join time (see `attachToolDetails`).
182
+ isImage: filePath && fileKind(filePath) === "image" ? true : undefined,
183
+ };
184
+ }
185
+ /**
186
+ * The read target's path relative to `baseDir` (the project dir), or undefined if
187
+ * it isn't an absolute path inside it. Used to point the web's inline `<img>` at
188
+ * the raw file endpoint, which resolves names against the same project dir — with
189
+ * its own traversal guard as a second line of defence (issue #239).
190
+ */
191
+ function servableRelPath(baseDir, absPath) {
192
+ if (!absPath || !path.isAbsolute(absPath))
193
+ return undefined;
194
+ const rel = path.relative(baseDir, absPath);
195
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
196
+ return undefined;
197
+ return rel;
198
+ }
199
+ /** Turn `gitOperation` (e.g. `{push:{branch:"main"}}`) into a short hint "push → main". */
200
+ function gitOperationHint(op) {
201
+ const rec = asRec(op);
202
+ if (!rec)
203
+ return undefined;
204
+ const entry = Object.entries(rec)[0];
205
+ if (!entry)
206
+ return undefined;
207
+ const [name, detail] = entry;
208
+ const branch = str(asRec(detail)?.branch);
209
+ return branch ? `${name} → ${branch}` : name;
210
+ }
211
+ /** Split stdout/stderr + status affordances for a Bash. Only produced when there's
212
+ * something beyond a clean stdout to show (else the generic output renders). */
213
+ function bashDetailsFromResult(result) {
214
+ if (!result)
215
+ return undefined;
216
+ const stderr = str(result.stderr);
217
+ const interrupted = result.interrupted === true;
218
+ const returnCodeInterpretation = str(result.returnCodeInterpretation);
219
+ const gitHint = gitOperationHint(result.gitOperation);
220
+ if (!stderr && !interrupted && !returnCodeInterpretation && !gitHint)
221
+ return undefined;
222
+ return {
223
+ // Carry stdout only when we're splitting off a stderr, so the render can show
224
+ // both distinctly without doubling every clean Bash call's payload.
225
+ stdout: stderr ? str(result.stdout) : undefined,
226
+ stderr,
227
+ interrupted: interrupted || undefined,
228
+ returnCodeInterpretation,
229
+ gitHint,
230
+ };
231
+ }
232
+ /** Match/file counts for a Grep/Glob. */
233
+ function searchInfoFromResult(toolName, result) {
234
+ if (!result)
235
+ return undefined;
236
+ if (toolName === "Grep") {
237
+ const mode = str(result.mode);
238
+ const info = {
239
+ kind: "grep",
240
+ numFiles: numOpt(result.numFiles),
241
+ numLines: mode === "content" ? numOpt(result.numLines) : undefined,
242
+ truncated: result.appliedLimit ? true : undefined,
243
+ };
244
+ return info.numFiles !== undefined || info.numLines !== undefined ? info : undefined;
245
+ }
246
+ // Glob
247
+ const info = {
248
+ kind: "glob",
249
+ numFiles: numOpt(result.numFiles),
250
+ totalMatches: numOpt(result.totalMatches),
251
+ truncated: result.truncated === true || undefined,
252
+ };
253
+ return info.numFiles !== undefined || info.totalMatches !== undefined ? info : undefined;
254
+ }
255
+ /** Status transition for a TaskUpdate. */
256
+ function taskUpdateFromResult(result) {
257
+ if (!result)
258
+ return undefined;
259
+ const sc = asRec(result.statusChange);
260
+ const updatedFields = Array.isArray(result.updatedFields)
261
+ ? result.updatedFields.filter((x) => typeof x === "string")
262
+ : undefined;
263
+ const info = {
264
+ taskId: id(result.taskId),
265
+ updatedFields: updatedFields?.length ? updatedFields : undefined,
266
+ from: str(sc?.from),
267
+ to: str(sc?.to),
268
+ };
269
+ if (!info.taskId && !info.from && !info.to && !info.updatedFields)
270
+ return undefined;
271
+ return info;
272
+ }
273
+ /** Subject/description for a TaskCreate. */
274
+ function taskCreateFromResult(input, result) {
275
+ const task = asRec(result?.task);
276
+ const subject = str(task?.subject) ?? str(input.subject);
277
+ const description = str(input.description) ?? str(input.prompt);
278
+ const taskId = id(task?.id);
279
+ if (!subject && !description && !taskId)
280
+ return undefined;
281
+ return { taskId, subject, description };
282
+ }
283
+ /** Derive the per-tool structured fields for one recovered tool_use. */
284
+ function deriveDetail(toolName, input, result) {
285
+ const detail = { toolName };
286
+ if (EDIT_TOOL_NAMES.has(toolName))
287
+ detail.editDiff = editDiffFromResult(toolName, input, result);
288
+ else if (toolName === "Read")
289
+ detail.readInfo = readInfoFromResult(input, result);
290
+ else if (toolName === "Bash")
291
+ detail.bashDetails = bashDetailsFromResult(result);
292
+ else if (toolName === "Grep" || toolName === "Glob")
293
+ detail.searchInfo = searchInfoFromResult(toolName, result);
294
+ else if (toolName === "TaskUpdate")
295
+ detail.taskUpdate = taskUpdateFromResult(result);
296
+ else if (toolName === "TaskCreate")
297
+ detail.taskCreate = taskCreateFromResult(input, result);
298
+ return detail;
299
+ }
300
+ /** The additive fields to spread onto the tool call, or null if none were derived. */
301
+ function extraFields(detail) {
302
+ const extra = {};
303
+ if (detail.editDiff)
304
+ extra.editDiff = detail.editDiff;
305
+ if (detail.readInfo)
306
+ extra.readInfo = detail.readInfo;
307
+ if (detail.bashDetails)
308
+ extra.bashDetails = detail.bashDetails;
309
+ if (detail.searchInfo)
310
+ extra.searchInfo = detail.searchInfo;
311
+ if (detail.taskUpdate)
312
+ extra.taskUpdate = detail.taskUpdate;
313
+ if (detail.taskCreate)
314
+ extra.taskCreate = detail.taskCreate;
315
+ return Object.keys(extra).length ? extra : null;
316
+ }
317
+ // --- recovery + join ------------------------------------------------------
318
+ /** Recover per-tool details (file-ordered, paired-only) from a session transcript. */
319
+ export async function readToolDetails(projectDir, sessionId) {
320
+ if (!SAFE_SEGMENT.test(sessionId))
321
+ return [];
322
+ return readToolDetailsFromFile(path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`));
323
+ }
324
+ async function readToolDetailsFromFile(file) {
325
+ const mtimeMs = await statMtimeMs(file);
326
+ if (mtimeMs !== undefined) {
327
+ const cached = mtimeCacheGet(detailsCache, file, mtimeMs);
328
+ if (cached.hit)
329
+ return cached.value;
330
+ }
331
+ const value = await readToolDetailsFromFileUncached(file);
332
+ if (mtimeMs !== undefined)
333
+ mtimeCacheSet(detailsCache, file, mtimeMs, value);
334
+ return value;
335
+ }
336
+ async function readToolDetailsFromFileUncached(file) {
337
+ const byId = new Map();
338
+ const order = [];
339
+ const resultIds = new Set();
340
+ // `toolUseResult` is a top-level field on the tool_result line, correlated to
341
+ // the tool_use via the (single) tool_result block's `tool_use_id`.
342
+ const resultById = new Map();
343
+ const stream = createReadStream(file, { encoding: "utf8" });
344
+ stream.on("error", () => undefined);
345
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
346
+ try {
347
+ for await (const line of rl) {
348
+ const trimmed = line.trim();
349
+ if (!trimmed)
350
+ continue;
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(trimmed);
354
+ }
355
+ catch {
356
+ continue;
357
+ }
358
+ const content = parsed.message?.content;
359
+ if (!Array.isArray(content))
360
+ continue;
361
+ // Collect this line's tool_result ids first — a single one lets us pin the
362
+ // line-level `toolUseResult` unambiguously (parallel results are rare and
363
+ // simply skip the sidecar rather than mis-attribute it).
364
+ const lineResultIds = [];
365
+ for (const block of content) {
366
+ const b = block;
367
+ if (b?.type === "tool_use" && b.name && DETAIL_TOOL_NAMES.has(b.name) && b.id) {
368
+ if (!byId.has(b.id)) {
369
+ order.push(b.id);
370
+ byId.set(b.id, { toolName: b.name, input: b.input ?? {} });
371
+ }
372
+ }
373
+ else if (b?.type === "tool_result" && typeof b.tool_use_id === "string") {
374
+ resultIds.add(b.tool_use_id);
375
+ lineResultIds.push(b.tool_use_id);
376
+ }
377
+ }
378
+ const tur = asRec(parsed.toolUseResult);
379
+ if (tur && lineResultIds.length === 1)
380
+ resultById.set(lineResultIds[0], tur);
381
+ }
382
+ }
383
+ catch {
384
+ return [];
385
+ }
386
+ finally {
387
+ rl.close();
388
+ stream.destroy();
389
+ }
390
+ return order
391
+ .filter((tid) => resultIds.has(tid))
392
+ .map((tid) => {
393
+ const use = byId.get(tid);
394
+ return deriveDetail(use.toolName, use.input, resultById.get(tid));
395
+ });
396
+ }
397
+ /** True when any parsed message is a tool call we derive detail for. */
398
+ function hasDetailTool(messages) {
399
+ return messages.some((m) => m.toolCall && DETAIL_TOOL_NAMES.has(m.toolCall.toolName));
400
+ }
401
+ /**
402
+ * Attach recovered per-tool detail to each detail-bearing tool message. The
403
+ * recovered details and the parsed messages are joined **per tool name, in file
404
+ * order** — the same paired-only/file-ordered invariant `enrichWithEdits`/
405
+ * `attachSubagentFields` rely on (which filter both sides to their tool set),
406
+ * bucketed by name so the defensive `toolName` match is intrinsic AND a tool herdctl
407
+ * happens to drop from its parsed stream (e.g. an interrupted, empty-output Bash)
408
+ * only misaligns its own family, never the whole tail. Sub-agent fields already
409
+ * attached by `enrichWithSubagents` are preserved (Task/Agent aren't in
410
+ * `DETAIL_TOOL_NAMES`). Detail-free sessions pass through unchanged.
411
+ */
412
+ export async function attachToolDetails(projectDir, sessionId, messages) {
413
+ if (!hasDetailTool(messages))
414
+ return messages;
415
+ const details = await readToolDetails(projectDir, sessionId);
416
+ // Bucket the recovered details by tool name, preserving file order within each.
417
+ const byName = new Map();
418
+ for (const d of details) {
419
+ const arr = byName.get(d.toolName);
420
+ if (arr)
421
+ arr.push(d);
422
+ else
423
+ byName.set(d.toolName, [d]);
424
+ }
425
+ const cursor = new Map();
426
+ return messages.map((m) => {
427
+ const tc = m.toolCall;
428
+ if (!tc || !DETAIL_TOOL_NAMES.has(tc.toolName))
429
+ return m;
430
+ // An in-flight (`pending`) tool_use is unpaired, so `readToolDetails`
431
+ // (paired-only, positionally joined) has no slot for it — core@5.24.0 injects
432
+ // it into the stream for rehydration (herdctl#399). Skip it before advancing
433
+ // the per-name cursor so it doesn't steal the next completed call's detail.
434
+ if (tc.pending)
435
+ return m;
436
+ const i = cursor.get(tc.toolName) ?? 0;
437
+ cursor.set(tc.toolName, i + 1);
438
+ const detail = byName.get(tc.toolName)?.[i];
439
+ if (!detail)
440
+ return m;
441
+ let extra = extraFields(detail);
442
+ if (!extra)
443
+ return m;
444
+ // For an image Read inside the project dir, add the servable relative path so
445
+ // the web can render it inline (issue #239). Clone rather than mutate the
446
+ // cached `detail.readInfo`.
447
+ if (extra.readInfo?.isImage) {
448
+ const rel = servableRelPath(projectDir, extra.readInfo.filePath);
449
+ if (rel)
450
+ extra = { ...extra, readInfo: { ...extra.readInfo, projectRelPath: rel } };
451
+ }
452
+ return { ...m, toolCall: { ...tc, ...extra } };
453
+ });
454
+ }
455
+ /**
456
+ * The single history-enrichment orchestrator (issue #237). Composes the three
457
+ * server-side passes so the routes call one function: sub-agent recovery (#37) →
458
+ * generalized per-tool detail (#237, subsuming #232's edit diff) → background /
459
+ * task-notification folding (#230) → local-command stdout recovery (#158).
460
+ */
461
+ export async function enrichWithToolDetails(projectDir, sessionId, messages) {
462
+ const withSubagents = await enrichWithSubagents(projectDir, sessionId, messages);
463
+ const withDetails = await attachToolDetails(projectDir, sessionId, withSubagents);
464
+ const withBackground = enrichWithBackground(withDetails);
465
+ return enrichWithLocalCommands(projectDir, sessionId, withBackground);
466
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Voice-dictation transcription — turns a recorded audio blob into text.
3
+ *
4
+ * Two backends, selected by {@link TranscriptionConfig.mode} (see config.ts):
5
+ *
6
+ * - `remote` — POST the audio to an OpenAI-compatible `/audio/transcriptions`
7
+ * endpoint (the same contract HushPod's remote whisper uses, so both can
8
+ * point at one shared server, e.g. a laptop with a GPU). Uses global
9
+ * `fetch` + `FormData`, so it is trivially stubbable in tests.
10
+ * - `local` — run whisper.cpp on this box via nodejs-whisper. The heavy,
11
+ * optional dependency (`./whisper-local.js`) is imported lazily so the
12
+ * server boots — and remote mode works — even when whisper.cpp / ffmpeg
13
+ * aren't installed.
14
+ *
15
+ * The composer's mic button is gated on {@link Transcriber.available}; a
16
+ * misconfigured or `off` instance simply advertises no dictation.
17
+ */
18
+ import type { TranscriptionConfig, WhisperMode } from "./config.js";
19
+ /** A recorded audio blob to transcribe (as uploaded by the browser). */
20
+ export interface TranscriptionInput {
21
+ audio: Buffer;
22
+ /** Original filename incl. extension, e.g. `dictation.webm`. */
23
+ filename: string;
24
+ /** MIME type, e.g. `audio/webm`. */
25
+ mimeType: string;
26
+ }
27
+ export interface TranscriptionResult {
28
+ /** The transcribed text (trimmed). */
29
+ text: string;
30
+ /** Which backend produced it. */
31
+ mode: Exclude<WhisperMode, "off">;
32
+ /** The whisper model used. */
33
+ model: string;
34
+ /** Wall-clock transcription time. */
35
+ durationMs: number;
36
+ }
37
+ /** Thrown on any transcription failure; carries an HTTP-ish status hint. */
38
+ export declare class TranscriptionError extends Error {
39
+ /** Suggested HTTP status for the route to surface (default 502). */
40
+ readonly status: number;
41
+ constructor(message: string,
42
+ /** Suggested HTTP status for the route to surface (default 502). */
43
+ status?: number);
44
+ }
45
+ export interface Transcriber {
46
+ /** Whether dictation is usable (mode !== off and required config present). */
47
+ readonly available: boolean;
48
+ readonly mode: WhisperMode;
49
+ readonly model: string;
50
+ transcribe(input: TranscriptionInput): Promise<TranscriptionResult>;
51
+ }
52
+ /** Runs whisper.cpp locally. Injected so the local path is testable/mockable. */
53
+ export type LocalRunner = (input: TranscriptionInput, cfg: TranscriptionConfig) => Promise<string>;
54
+ export interface TranscriberDeps {
55
+ /** Override `globalThis.fetch` (tests). */
56
+ fetchImpl?: typeof fetch;
57
+ /** Override the local whisper.cpp runner (tests). Defaults to a lazy import. */
58
+ localRunner?: LocalRunner;
59
+ }
60
+ /**
61
+ * Build a {@link Transcriber} from resolved config. Pure — constructs no I/O
62
+ * until `transcribe()` is called, so it's safe to make one at startup regardless
63
+ * of mode.
64
+ */
65
+ export declare function makeTranscriber(cfg: TranscriptionConfig, deps?: TranscriberDeps): Transcriber;
66
+ /** Pull the transcript out of an OpenAI-ish response body (JSON `{text}` or raw). */
67
+ export declare function extractText(raw: string): string | null;
68
+ /** Join a base URL and path with exactly one slash between them. */
69
+ export declare function joinUrl(base: string, path: string): string;
@@ -0,0 +1,119 @@
1
+ /** Thrown on any transcription failure; carries an HTTP-ish status hint. */
2
+ export class TranscriptionError extends Error {
3
+ status;
4
+ constructor(message,
5
+ /** Suggested HTTP status for the route to surface (default 502). */
6
+ status = 502) {
7
+ super(message);
8
+ this.status = status;
9
+ this.name = "TranscriptionError";
10
+ }
11
+ }
12
+ /**
13
+ * Build a {@link Transcriber} from resolved config. Pure — constructs no I/O
14
+ * until `transcribe()` is called, so it's safe to make one at startup regardless
15
+ * of mode.
16
+ */
17
+ export function makeTranscriber(cfg, deps = {}) {
18
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
19
+ // `remote` requires an endpoint; without one it degrades to unavailable rather
20
+ // than 500-ing at request time.
21
+ const available = cfg.mode === "local" || (cfg.mode === "remote" && !!cfg.endpoint);
22
+ async function transcribe(input) {
23
+ if (!available) {
24
+ throw new TranscriptionError("transcription is not configured on this instance", 503);
25
+ }
26
+ if (input.audio.length === 0) {
27
+ throw new TranscriptionError("empty audio upload", 400);
28
+ }
29
+ const started = hrNow();
30
+ const text = cfg.mode === "remote"
31
+ ? await transcribeRemote(input, cfg, fetchImpl)
32
+ : await runLocal(input, cfg, deps.localRunner);
33
+ return {
34
+ text: text.trim(),
35
+ mode: cfg.mode,
36
+ model: cfg.model,
37
+ durationMs: Math.round(hrNow() - started),
38
+ };
39
+ }
40
+ return { available, mode: cfg.mode, model: cfg.model, transcribe };
41
+ }
42
+ /** Lazy-import the local runner so nodejs-whisper stays an optional dependency. */
43
+ async function runLocal(input, cfg, injected) {
44
+ const runner = injected ?? (await import("./whisper-local.js")).transcribeLocal;
45
+ return runner(input, cfg);
46
+ }
47
+ /**
48
+ * OpenAI-compatible transcription: `POST {endpoint}/audio/transcriptions` with a
49
+ * multipart `file` + `model`. This is the same wire format as OpenAI's Whisper
50
+ * API and whisper-server / faster-whisper-server / speaches, so any of them (or
51
+ * HushPod's remote whisper) works unchanged.
52
+ */
53
+ async function transcribeRemote(input, cfg, fetchImpl) {
54
+ const url = joinUrl(cfg.endpoint, "audio/transcriptions");
55
+ const form = new FormData();
56
+ form.append("file", new Blob([input.audio], { type: input.mimeType }), input.filename);
57
+ form.append("model", cfg.model);
58
+ form.append("response_format", "json");
59
+ if (cfg.language)
60
+ form.append("language", cfg.language);
61
+ const headers = {};
62
+ if (cfg.apiKey)
63
+ headers.authorization = `Bearer ${cfg.apiKey}`;
64
+ let res;
65
+ try {
66
+ res = await fetchImpl(url, {
67
+ method: "POST",
68
+ headers,
69
+ body: form,
70
+ signal: AbortSignal.timeout(120_000),
71
+ });
72
+ }
73
+ catch (err) {
74
+ const reason = err instanceof Error ? err.message : String(err);
75
+ throw new TranscriptionError(`whisper server unreachable at ${url}: ${reason}`, 502);
76
+ }
77
+ if (!res.ok) {
78
+ const body = await res.text().catch(() => "");
79
+ throw new TranscriptionError(`whisper server returned ${res.status}${body ? `: ${truncate(body, 200)}` : ""}`, 502);
80
+ }
81
+ // OpenAI's default `json` response is `{ text }`; `verbose_json` adds more.
82
+ // Be defensive: accept a raw string body too (some servers return text/plain).
83
+ const raw = await res.text();
84
+ const text = extractText(raw);
85
+ if (text === null) {
86
+ throw new TranscriptionError("whisper server returned no transcript text", 502);
87
+ }
88
+ return text;
89
+ }
90
+ /** Pull the transcript out of an OpenAI-ish response body (JSON `{text}` or raw). */
91
+ export function extractText(raw) {
92
+ const trimmed = raw.trim();
93
+ if (!trimmed)
94
+ return null;
95
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
96
+ try {
97
+ const data = JSON.parse(trimmed);
98
+ if (typeof data.text === "string")
99
+ return data.text;
100
+ return null;
101
+ }
102
+ catch {
103
+ // Not JSON after all — fall through and treat as raw text.
104
+ }
105
+ }
106
+ return trimmed;
107
+ }
108
+ /** Join a base URL and path with exactly one slash between them. */
109
+ export function joinUrl(base, path) {
110
+ return `${base.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`;
111
+ }
112
+ function truncate(s, n) {
113
+ return s.length > n ? `${s.slice(0, n)}…` : s;
114
+ }
115
+ // `Date.now()` is fine in the server runtime; kept behind a helper so the
116
+ // duration math reads clearly and can be stubbed if ever needed.
117
+ function hrNow() {
118
+ return Date.now();
119
+ }